~kota/metweather

629073ee79ebdc0fa9e11e902d6d978caee249af — Dakota Walsh 4 months ago 4bcb056
implement observation command
5 files changed, 215 insertions(+), 6 deletions(-)

A cmd/data/localObs_Dunedin.json
A cmd/observation.go
A cmd/observation_test.go
M go.mod
M go.sum
A cmd/data/localObs_Dunedin.json => cmd/data/localObs_Dunedin.json +26 -0
@@ 0,0 1,26 @@
{
	"_usage": "This data is restricted and may only be used with explicit permission from MetService NZ. Contact dataenquiries@metservice.com",
	"id": "loc1036728",
	"location": "Dunedin",
	"locationId": "93892",
	"threeHour": {
		"clothingLayers": "2",
		"dateTime": "8:00pm Wednesday 21 Jul 2021",
		"dateTimeISO": "2021-07-21T20:00:00+12:00",
		"humidity": "77",
		"pressure": "n/a",
		"rainfall": "0.0",
		"rawTime": 1626854400000,
		"temp": "9",
		"windChill": "8",
		"windDirection": "W",
		"windProofLayers": "0",
		"windSpeed": "2"
	},
	"twentyFourHour": {
		"dateTime": "9:00am Wednesday 21 Jul 2021",
		"maxTemp": 10,
		"minTemp": 4,
		"rainfall": "0.0"
	}
}

A cmd/observation.go => cmd/observation.go +112 -0
@@ 0,0 1,112 @@
package cmd

import (
	"context"
	"fmt"
	"log"
	"math"

	"git.sr.ht/~kota/metservice-go"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

// observationCmd represents the observation command
var observationCmd = &cobra.Command{
	Use:   "observation",
	Short: "display current or past weather observations",
	Run:   observation,
}

func init() {
	rootCmd.AddCommand(observationCmd)
	observationCmd.PersistentFlags().StringP("location", "l", "", "Location used for the weather observation")
	viper.BindPFlag("location", observationCmd.PersistentFlags().Lookup("location"))
}

// observation fetches and prints an observation based on options provided
func observation(cmd *cobra.Command, args []string) {
	client := metservice.NewClient()
	ctx := context.Background()
	location := viper.GetString("location")
	if location == "" {
		log.Fatal("location is required either using the flag or config")
	}
	err := observationThreeHour(client, ctx, location)
	if err != nil {
		log.Fatal(err)
	}
}

// observationThreeHour fetches and prints an observation of the last three
// hours
func observationThreeHour(client *metservice.Client, ctx context.Context, location string) error {
	o, _, err := client.GetObservation(ctx, location)
	if err != nil {
		return fmt.Errorf("getting observation: %v", err)
	}
	fmt.Fprintf(out, "%d°C ", *o.ThreeHour.Temp)
	fmt.Fprintf(out, "%dkm/h ", *o.ThreeHour.WindSpeed)
	fmt.Fprintf(out, "%s - ", *o.ThreeHour.WindDirection)
	fmt.Fprintf(out, "%d%%", *o.ThreeHour.Humidity)
	fmt.Fprintf(out, "(%.2f°C)\n", feelsLike(*o.ThreeHour.Temp, *o.ThreeHour.Humidity, *o.ThreeHour.WindSpeed))
	return nil
}

// feelsLike calculates an estimated temperate that considers your ability to
// heat or cool yourself. We use the same formula described by Metservice here
// https://blog.metservice.com/FeelsLikeTemp
// When the measured air temperature is below 10°C the windChill algorithm is
// used. If it is above 14°C the apparentTemp is calculated and used if it's it
// higher than the measured air temp. For values between 10-14°C a pragmatic
// linear roll-off of the wind chill is used. So 12°C at 5km/h wind has a
// windChill of 12°C.
// NOTE: The windChill formula listen on the Metservice website is incorrect.
func feelsLike(temp int, humidity int, speed int) float64 {
	// return whichever is higher of apparentTemp and temp
	if temp >= 14 {
		t := float64(temp)
		at := apparentTemp(temp, humidity, speed)
		return math.Max(t, at)
	}
	w := windChill(temp, speed)
	// return windChill
	if temp < 10 {
		return w
	}
	// linear roll-off between windChill and temp
	t := float64(temp)
	f := t - (((t - w) * (14 - t)) / 4)
	return f
}

// windChill calculates the wind chill temperature in degrees Celsius based on
// the following algorithm
// https://web.archive.org/web/20060415000715/http://www.msc.ec.gc.ca/education/windchill/Science_equations_e.cfm
// w = 13.12 + 0.6215*t - 11.35*k^0.16 + 0.396*t*k^0.16
// t = Dry bulb temperature (°C)
// k = Average wind speed in km/h
func windChill(temp int, speed int) float64 {
	t := float64(temp)
	k := math.Pow(float64(speed), 0.16)
	w := 13.12 + 0.6215*t - 11.35*k + 0.396*t*k
	return w
}

// apparentTemp calculates the apparent temperature in degrees Celsius based on
// the following algorithm http://www.bom.gov.au/info/thermal_stress/
// at = t + 0.33e - 0.70m - 4.00
// t = Dry bulb temperature (°C)
// e = Water vapour pressure (hPa) [humidity]
// m = Wind speed (m/s) at an elevation of 10 meters
// our function takes the values in the following formats and converts them
// temp = Air temperature (°C) int
// humidity = Relative humidity percentage int (0-100)
// speed = Wind speed km/h int
func apparentTemp(temp int, humidity int, speed int) float64 {
	t := float64(temp)
	e := float64(humidity) / 100 * 6.105 * (17.27 * t / (237.7 + t))
	m := float64(speed) * (5 / 18)
	at := t + 0.33*e - 0.70*m - 4.00
	return at
}

A cmd/observation_test.go => cmd/observation_test.go +65 -0
@@ 0,0 1,65 @@
package cmd

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"os"
	"testing"
)

func TestObservationThreeHour(t *testing.T) {
	client, mux, teardown := setup()
	defer teardown()

	mux.HandleFunc("/localObs_Dunedin", func(w http.ResponseWriter, r *http.Request) {
		reader, err := os.Open("data/localObs_Dunedin.json")
		if err != nil {
			t.Errorf("metweather.observationThreeHour returned error: %v", err)
		}
		io.Copy(w, reader)
	})

	ctx := context.Background()
	out = new(bytes.Buffer) // captured output
	err := observationThreeHour(client, ctx, "Dunedin")
	if err != nil {
		t.Errorf("metweather.observationThreeHour returned error: %v", err)
	}
	got := out.(*bytes.Buffer).String()
	want := "9°C 2km/h W - 77%(10.01°C)\n"
	if got != want {
		t.Errorf("metweather.observationThreeHour\ngot = %q\nwant = %q", got, want)
	}
}

func TestWindChill(t *testing.T) {
	var tests = []struct {
		temp  int
		speed int
		want  float64
	}{
		{12, 5, 12.042135509662362},
		{12, 10, 11.04098839261837},
		{12, 25, 9.535110852166257},
		{12, 50, 8.239921987968058},
		{9, 5, 8.640714167661585},
		{9, 10, 7.459305944972208},
		{9, 25, 5.682288283565697},
		{9, 50, 4.153894755731934},
		{0, 5, -1.5635498583407408},
		{0, 10, -3.2857413979662784},
		{0, 25, -5.87617942223598},
		{0, 50, -8.104186940976438},
	}
	for _, test := range tests {
		if got := windChill(test.temp, test.speed); got != test.want {
			t.Errorf("metweather.windChill\ntemp = %d\nspeed = %d\nwant = %f\n got = %f\n",
				test.temp,
				test.speed,
				test.want,
				got)
		}
	}
}

M go.mod => go.mod +4 -1
@@ 3,8 3,11 @@ module git.sr.ht/~kota/metweather
go 1.16

require (
	git.sr.ht/~kota/metservice-go v1.0.0
	git.sr.ht/~kota/metservice-go v1.0.1
	github.com/mitchellh/go-homedir v1.1.0
	github.com/spf13/cast v1.4.0 // indirect
	github.com/spf13/cobra v1.2.1
	github.com/spf13/viper v1.8.1
	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
	golang.org/x/text v0.3.6 // indirect
)

M go.sum => go.sum +8 -5
@@ 37,8 37,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.sr.ht/~kota/metservice-go v1.0.0 h1:PJOtNd//RdqJwitpPQzjMDb9fGTibOSPzo9N3+0mAos=
git.sr.ht/~kota/metservice-go v1.0.0/go.mod h1:ruGNU5HLsIa4TPYAiB2FHPUTk3Ya4/uRT06/OLCZB4c=
git.sr.ht/~kota/metservice-go v1.0.1 h1:XjQjIgBywJ3la2RQnpeU82WGU9oEnsJ+8Sch0vb8yGE=
git.sr.ht/~kota/metservice-go v1.0.1/go.mod h1:ruGNU5HLsIa4TPYAiB2FHPUTk3Ya4/uRT06/OLCZB4c=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=


@@ 221,8 221,9 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.4.0 h1:WhlbjwB9EGCc8W5Rxdkus+wmH2ASRwwTJk6tgHKwdqQ=
github.com/spf13/cast v1.4.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=


@@ 400,8 401,9 @@ golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=


@@ 409,8 411,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=