Add a /conditions/current endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -18,6 +18,7 @@ type Service interface {
|
|||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||||
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
|
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
|
CurrentConditions(ctx context.Context) (*core.CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type queryRequest struct {
|
type queryRequest struct {
|
||||||
@@ -65,6 +66,19 @@ func Definitions(svc Service) []endpoint.Definition {
|
|||||||
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||||
endpoint.WithTemplate("alerts_active.txt.tmpl"),
|
endpoint.WithTemplate("alerts_active.txt.tmpl"),
|
||||||
),
|
),
|
||||||
|
endpoint.GET(
|
||||||
|
"/conditions/current",
|
||||||
|
bindQuery,
|
||||||
|
func(ctx context.Context, req queryRequest) (any, error) {
|
||||||
|
conditions, err := svc.CurrentConditions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Envelope{Data: core.CurrentConditionsPayload(conditions, req.Units)}, nil
|
||||||
|
},
|
||||||
|
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||||
|
endpoint.WithTemplate("conditions_current.txt.tmpl"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/templates"
|
"gitea.maximumdirect.net/ejr/feedapi/templates"
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/transport/httpx"
|
"gitea.maximumdirect.net/ejr/feedapi/transport/httpx"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/core"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type fakeService struct {
|
|||||||
observation *model.WeatherObservation
|
observation *model.WeatherObservation
|
||||||
forecast *model.WeatherForecastRun
|
forecast *model.WeatherForecastRun
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
|
conditions *core.CurrentConditions
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ func (s *fakeService) LatestActiveAlerts(context.Context) (*model.WeatherAlertRu
|
|||||||
return s.alerts, s.err
|
return s.alerts, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fakeService) CurrentConditions(context.Context) (*core.CurrentConditions, error) {
|
||||||
|
return s.conditions, s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{}, "/observations")
|
h := newHandler(t, &fakeService{}, "/observations")
|
||||||
|
|
||||||
@@ -216,6 +222,218 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
observation: &model.WeatherObservation{
|
||||||
|
StationID: "KSTL",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
ConditionCode: 1,
|
||||||
|
TemperatureC: float64Ptr(20),
|
||||||
|
},
|
||||||
|
}, "/observations")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/observations?format=xml&units=us", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
|
||||||
|
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "temperatureC") {
|
||||||
|
t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "temperatureF") {
|
||||||
|
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
forecast: &model.WeatherForecastRun{
|
||||||
|
Product: model.ForecastProductHourly,
|
||||||
|
IssuedAt: time.Now().UTC(),
|
||||||
|
Periods: []model.WeatherForecastPeriod{{
|
||||||
|
StartTime: time.Now().UTC(),
|
||||||
|
EndTime: time.Now().UTC().Add(time.Hour),
|
||||||
|
ConditionCode: 1,
|
||||||
|
TemperatureC: float64Ptr(10),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}, "/forecast/hourly")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=XML&units=US", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
|
||||||
|
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, "temperatureC") {
|
||||||
|
t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "temperatureF") {
|
||||||
|
t.Fatalf("expected US field temperatureF in XML payload: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/conditions/current")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data *json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data != nil {
|
||||||
|
t.Fatalf("expected data null, got %s", string(*payload.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
conditions: &core.CurrentConditions{
|
||||||
|
TemperatureC: float64Ptr(10),
|
||||||
|
ApparentTemperatureC: float64Ptr(9),
|
||||||
|
DewpointC: float64Ptr(5),
|
||||||
|
RelativeHumidityPercent: float64Ptr(75),
|
||||||
|
WindSpeedKmh: float64Ptr(18),
|
||||||
|
WindDirectionDegrees: float64Ptr(135),
|
||||||
|
ConditionCode: 63,
|
||||||
|
},
|
||||||
|
}, "/conditions/current")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["temperatureC"]; !ok {
|
||||||
|
t.Fatalf("expected temperatureC in metric payload")
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["temperatureF"]; ok {
|
||||||
|
t.Fatalf("expected temperatureF omitted in metric payload")
|
||||||
|
}
|
||||||
|
if payload.Data["conditionText"] != "Rain" {
|
||||||
|
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsUSJSON(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
conditions: &core.CurrentConditions{
|
||||||
|
TemperatureC: float64Ptr(10),
|
||||||
|
ApparentTemperatureC: float64Ptr(9),
|
||||||
|
DewpointC: float64Ptr(5),
|
||||||
|
RelativeHumidityPercent: float64Ptr(75),
|
||||||
|
WindSpeedKmh: float64Ptr(18),
|
||||||
|
WindDirectionDegrees: float64Ptr(135),
|
||||||
|
ConditionCode: 63,
|
||||||
|
},
|
||||||
|
}, "/conditions/current")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/conditions/current?units=us", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["temperatureF"]; !ok {
|
||||||
|
t.Fatalf("expected temperatureF in us payload")
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["temperatureC"]; ok {
|
||||||
|
t.Fatalf("expected temperatureC omitted in us payload")
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["windSpeedMph"]; !ok {
|
||||||
|
t.Fatalf("expected windSpeedMph in us payload")
|
||||||
|
}
|
||||||
|
if _, ok := payload.Data["windSpeedKmh"]; ok {
|
||||||
|
t.Fatalf("expected windSpeedKmh omitted in us payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
conditions: &core.CurrentConditions{
|
||||||
|
TemperatureC: float64Ptr(10),
|
||||||
|
WindSpeedKmh: float64Ptr(18),
|
||||||
|
ConditionCode: 2,
|
||||||
|
},
|
||||||
|
}, "/conditions/current")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/conditions/current?format=xml&units=us", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
|
||||||
|
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "temperatureF") {
|
||||||
|
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for text request, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
||||||
|
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "Conditions text") {
|
||||||
|
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsRejectUnknownQueryParameter(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/conditions/current")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/conditions/current?bogus=1", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newHandler(t *testing.T, svc Service, path string) http.Handler {
|
func newHandler(t *testing.T, svc Service, path string) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -250,9 +468,10 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
|
|
||||||
tmplReg := templates.NewRegistry()
|
tmplReg := templates.NewRegistry()
|
||||||
for name, body := range map[string]string{
|
for name, body := range map[string]string{
|
||||||
"observations.txt.tmpl": "Observation text",
|
"observations.txt.tmpl": "Observation text",
|
||||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "Alerts text",
|
||||||
|
"conditions_current.txt.tmpl": "Conditions text",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,6 +36,51 @@ FROM observations
|
|||||||
ORDER BY observed_at DESC, event_emitted_at DESC
|
ORDER BY observed_at DESC, event_emitted_at DESC
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
|
|
||||||
|
queryCurrentConditions = `
|
||||||
|
WITH windowed AS (
|
||||||
|
SELECT
|
||||||
|
temperature_c,
|
||||||
|
apparent_temperature_c,
|
||||||
|
dewpoint_c,
|
||||||
|
relative_humidity_percent,
|
||||||
|
wind_speed_kmh,
|
||||||
|
wind_direction_degrees,
|
||||||
|
condition_code,
|
||||||
|
is_day,
|
||||||
|
observed_at
|
||||||
|
FROM observations
|
||||||
|
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS sample_count,
|
||||||
|
AVG(temperature_c) AS temperature_c,
|
||||||
|
AVG(apparent_temperature_c) AS apparent_temperature_c,
|
||||||
|
AVG(dewpoint_c) AS dewpoint_c,
|
||||||
|
AVG(relative_humidity_percent) AS relative_humidity_percent,
|
||||||
|
AVG(wind_speed_kmh) AS wind_speed_kmh,
|
||||||
|
CASE
|
||||||
|
WHEN atan2d(
|
||||||
|
AVG(sind(wind_direction_degrees)),
|
||||||
|
AVG(cosd(wind_direction_degrees))
|
||||||
|
) < 0
|
||||||
|
THEN atan2d(
|
||||||
|
AVG(sind(wind_direction_degrees)),
|
||||||
|
AVG(cosd(wind_direction_degrees))
|
||||||
|
) + 360.0
|
||||||
|
ELSE atan2d(
|
||||||
|
AVG(sind(wind_direction_degrees)),
|
||||||
|
AVG(cosd(wind_direction_degrees))
|
||||||
|
)
|
||||||
|
END AS wind_direction_degrees,
|
||||||
|
MAX(condition_code) AS condition_code,
|
||||||
|
(
|
||||||
|
SELECT is_day
|
||||||
|
FROM windowed
|
||||||
|
ORDER BY observed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS is_day
|
||||||
|
FROM windowed`
|
||||||
|
|
||||||
queryObservationPresentWeather = `
|
queryObservationPresentWeather = `
|
||||||
SELECT weather_index, raw_text
|
SELECT weather_index, raw_text
|
||||||
FROM observation_present_weather
|
FROM observation_present_weather
|
||||||
@@ -193,6 +238,33 @@ func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObser
|
|||||||
return &obs, nil
|
return &obs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*core.CurrentConditions, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row currentConditionsRow
|
||||||
|
err := r.db.QueryRowContext(ctx, queryCurrentConditions, observationWindowMinutes).Scan(
|
||||||
|
&row.SampleCount,
|
||||||
|
&row.TemperatureC,
|
||||||
|
&row.ApparentTemperatureC,
|
||||||
|
&row.DewpointC,
|
||||||
|
&row.RelativeHumidityPercent,
|
||||||
|
&row.WindSpeedKmh,
|
||||||
|
&row.WindDirectionDegrees,
|
||||||
|
&row.ConditionCode,
|
||||||
|
&row.IsDay,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query current conditions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCurrentConditionsRow(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
|
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, fmt.Errorf("postgres repository is not configured")
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
@@ -419,6 +491,40 @@ type observationParentRow struct {
|
|||||||
ApparentTemperatureC sql.NullFloat64
|
ApparentTemperatureC sql.NullFloat64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type currentConditionsRow struct {
|
||||||
|
SampleCount int64
|
||||||
|
TemperatureC sql.NullFloat64
|
||||||
|
ApparentTemperatureC sql.NullFloat64
|
||||||
|
DewpointC sql.NullFloat64
|
||||||
|
RelativeHumidityPercent sql.NullFloat64
|
||||||
|
WindSpeedKmh sql.NullFloat64
|
||||||
|
WindDirectionDegrees sql.NullFloat64
|
||||||
|
ConditionCode sql.NullInt64
|
||||||
|
IsDay sql.NullBool
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCurrentConditionsRow(row currentConditionsRow) *core.CurrentConditions {
|
||||||
|
if row.SampleCount == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conditionCode := model.WMOUnknown
|
||||||
|
if row.ConditionCode.Valid {
|
||||||
|
conditionCode = model.WMOCode(row.ConditionCode.Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &core.CurrentConditions{
|
||||||
|
TemperatureC: float64Ptr(row.TemperatureC),
|
||||||
|
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
||||||
|
DewpointC: float64Ptr(row.DewpointC),
|
||||||
|
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
|
||||||
|
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
|
||||||
|
ConditionCode: conditionCode,
|
||||||
|
IsDay: boolPtr(row.IsDay),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
|
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
|
||||||
return model.WeatherObservation{
|
return model.WeatherObservation{
|
||||||
StationID: stringValue(row.StationID),
|
StationID: stringValue(row.StationID),
|
||||||
|
|||||||
@@ -110,3 +110,54 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
|||||||
t.Fatalf("unexpected second alert references: %+v", out[1].References)
|
t.Fatalf("unexpected second alert references: %+v", out[1].References)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
||||||
|
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||||
|
SampleCount: 0,
|
||||||
|
})
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
|
||||||
|
isDay := true
|
||||||
|
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||||
|
SampleCount: 12,
|
||||||
|
TemperatureC: sql.NullFloat64{Float64: 15.5, Valid: true},
|
||||||
|
ApparentTemperatureC: sql.NullFloat64{Float64: 14.2, Valid: true},
|
||||||
|
DewpointC: sql.NullFloat64{Float64: 10.1, Valid: true},
|
||||||
|
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
||||||
|
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
||||||
|
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
||||||
|
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
|
||||||
|
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
||||||
|
})
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("expected mapped current conditions")
|
||||||
|
}
|
||||||
|
if got.TemperatureC == nil || *got.TemperatureC != 15.5 {
|
||||||
|
t.Fatalf("expected temperature pointer 15.5, got %v", got.TemperatureC)
|
||||||
|
}
|
||||||
|
if got.ApparentTemperatureC == nil || *got.ApparentTemperatureC != 14.2 {
|
||||||
|
t.Fatalf("expected apparent temp pointer 14.2, got %v", got.ApparentTemperatureC)
|
||||||
|
}
|
||||||
|
if got.DewpointC == nil || *got.DewpointC != 10.1 {
|
||||||
|
t.Fatalf("expected dewpoint pointer 10.1, got %v", got.DewpointC)
|
||||||
|
}
|
||||||
|
if got.RelativeHumidityPercent == nil || *got.RelativeHumidityPercent != 72 {
|
||||||
|
t.Fatalf("expected rh pointer 72, got %v", got.RelativeHumidityPercent)
|
||||||
|
}
|
||||||
|
if got.WindSpeedKmh == nil || *got.WindSpeedKmh != 24.8 {
|
||||||
|
t.Fatalf("expected wind speed pointer 24.8, got %v", got.WindSpeedKmh)
|
||||||
|
}
|
||||||
|
if got.WindDirectionDegrees == nil || *got.WindDirectionDegrees != 182.5 {
|
||||||
|
t.Fatalf("expected wind direction pointer 182.5, got %v", got.WindDirectionDegrees)
|
||||||
|
}
|
||||||
|
if got.ConditionCode != 65 {
|
||||||
|
t.Fatalf("expected condition code 65, got %d", got.ConditionCode)
|
||||||
|
}
|
||||||
|
if got.IsDay == nil || !*got.IsDay {
|
||||||
|
t.Fatalf("expected isDay pointer true, got %v", got.IsDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const (
|
|||||||
UnitsUS Units = "us"
|
UnitsUS Units = "us"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ObservationWindowMinutesDefault = 30
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
celsiusToFahrenheitScale = 9.0 / 5.0
|
celsiusToFahrenheitScale = 9.0 / 5.0
|
||||||
celsiusToFahrenheitOffset = 32.0
|
celsiusToFahrenheitOffset = 32.0
|
||||||
|
|||||||
15
internal/core/current_conditions.go
Normal file
15
internal/core/current_conditions.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
|
||||||
|
// CurrentConditions is an averaged current-conditions aggregate over a recent window.
|
||||||
|
type CurrentConditions struct {
|
||||||
|
TemperatureC *float64
|
||||||
|
ApparentTemperatureC *float64
|
||||||
|
DewpointC *float64
|
||||||
|
RelativeHumidityPercent *float64
|
||||||
|
WindSpeedKmh *float64
|
||||||
|
WindDirectionDegrees *float64
|
||||||
|
ConditionCode model.WMOCode
|
||||||
|
IsDay *bool
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WeatherObservationUS is the US-customary response shape for observations.
|
// WeatherObservationUS is the US-customary response shape for observations.
|
||||||
@@ -69,6 +70,24 @@ type WeatherForecastPeriodUS struct {
|
|||||||
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
|
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CurrentConditionsResponse is the response shape for /conditions/current.
|
||||||
|
// Unit-bearing fields are populated according to the requested unit mode.
|
||||||
|
type CurrentConditionsResponse struct {
|
||||||
|
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
||||||
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
||||||
|
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
||||||
|
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
||||||
|
IsDayText string `json:"-" xml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
func ObservationPayload(obs *model.WeatherObservation, units Units) any {
|
func ObservationPayload(obs *model.WeatherObservation, units Units) any {
|
||||||
if obs == nil {
|
if obs == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -155,6 +174,34 @@ func AlertsPayload(run *model.WeatherAlertRun, _ Units) any {
|
|||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CurrentConditionsPayload(conditions *CurrentConditions, units Units) any {
|
||||||
|
if conditions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := CurrentConditionsResponse{
|
||||||
|
RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent),
|
||||||
|
WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees),
|
||||||
|
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
||||||
|
IsDay: copyBoolPtr(conditions.IsDay),
|
||||||
|
IsDayText: boolText(conditions.IsDay),
|
||||||
|
}
|
||||||
|
|
||||||
|
if units == UnitsUS {
|
||||||
|
out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC)
|
||||||
|
out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC)
|
||||||
|
out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC)
|
||||||
|
out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC)
|
||||||
|
out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC)
|
||||||
|
out.DewpointC = copyFloat64Ptr(conditions.DewpointC)
|
||||||
|
out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func celsiusToFahrenheitPtr(v *float64) *float64 {
|
func celsiusToFahrenheitPtr(v *float64) *float64 {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -194,3 +241,13 @@ func copyTimePtr(v *time.Time) *time.Time {
|
|||||||
out := *v
|
out := *v
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolText(v *bool) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if *v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,12 +102,73 @@ func TestMetricPassthroughAndNilHandling(t *testing.T) {
|
|||||||
if AlertsPayload(nil, UnitsUS) != nil {
|
if AlertsPayload(nil, UnitsUS) != nil {
|
||||||
t.Fatalf("expected nil alerts input to return nil payload")
|
t.Fatalf("expected nil alerts input to return nil payload")
|
||||||
}
|
}
|
||||||
|
if CurrentConditionsPayload(nil, UnitsUS) != nil {
|
||||||
|
t.Fatalf("expected nil current conditions input to return nil payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
||||||
|
conditions := &CurrentConditions{
|
||||||
|
TemperatureC: float64Ptr(20),
|
||||||
|
ApparentTemperatureC: float64Ptr(18),
|
||||||
|
DewpointC: float64Ptr(10),
|
||||||
|
RelativeHumidityPercent: float64Ptr(55),
|
||||||
|
WindSpeedKmh: float64Ptr(100),
|
||||||
|
WindDirectionDegrees: float64Ptr(225),
|
||||||
|
ConditionCode: 0,
|
||||||
|
IsDay: boolPtr(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric)
|
||||||
|
metric, ok := metricPayload.(CurrentConditionsResponse)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
|
||||||
|
}
|
||||||
|
assertApprox(t, metric.TemperatureC, 20, 0.0001)
|
||||||
|
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
|
||||||
|
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
|
||||||
|
t.Fatalf("expected US fields omitted for metric payload")
|
||||||
|
}
|
||||||
|
if metric.ConditionText != "Sunny" {
|
||||||
|
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
||||||
|
}
|
||||||
|
|
||||||
|
usPayload := CurrentConditionsPayload(conditions, UnitsUS)
|
||||||
|
us, ok := usPayload.(CurrentConditionsResponse)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
|
||||||
|
}
|
||||||
|
assertApprox(t, us.TemperatureF, 68, 0.0001)
|
||||||
|
assertApprox(t, us.WindSpeedMph, 62.1371192237, 0.0001)
|
||||||
|
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
||||||
|
t.Fatalf("expected metric fields omitted for US payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
||||||
|
night := false
|
||||||
|
payload := CurrentConditionsPayload(&CurrentConditions{
|
||||||
|
ConditionCode: 0,
|
||||||
|
IsDay: &night,
|
||||||
|
}, UnitsMetric)
|
||||||
|
|
||||||
|
metric, ok := payload.(CurrentConditionsResponse)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
|
||||||
|
}
|
||||||
|
if metric.ConditionText != "Clear" {
|
||||||
|
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func float64Ptr(v float64) *float64 {
|
func float64Ptr(v float64) *float64 {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
func assertApprox(t *testing.T, got *float64, want, eps float64) {
|
func assertApprox(t *testing.T, got *float64, want, eps float64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got == nil {
|
if got == nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Repository interface {
|
|||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||||
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
|
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
|
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides weather read use-cases.
|
// Service provides weather read use-cases.
|
||||||
@@ -33,3 +34,7 @@ func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForec
|
|||||||
func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
|
func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
|
||||||
return s.repo.LatestActiveAlerts(ctx)
|
return s.repo.LatestActiveAlerts(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
|
||||||
|
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
|
||||||
|
}
|
||||||
|
|||||||
41
templates/conditions_current.txt.tmpl
Normal file
41
templates/conditions_current.txt.tmpl
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{{- if .Data -}}
|
||||||
|
Current Conditions
|
||||||
|
{{- if .Data.ConditionText}}
|
||||||
|
Condition: {{.Data.ConditionText}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.IsDayText}}
|
||||||
|
Is Day: {{.Data.IsDayText}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.TemperatureC}}
|
||||||
|
Temperature (C): {{.Data.TemperatureC}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.ApparentTemperatureC}}
|
||||||
|
Apparent Temperature (C): {{.Data.ApparentTemperatureC}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.DewpointC}}
|
||||||
|
Dewpoint (C): {{.Data.DewpointC}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.WindSpeedKmh}}
|
||||||
|
Wind Speed (km/h): {{.Data.WindSpeedKmh}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.TemperatureF}}
|
||||||
|
Temperature (F): {{.Data.TemperatureF}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.ApparentTemperatureF}}
|
||||||
|
Apparent Temperature (F): {{.Data.ApparentTemperatureF}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.DewpointF}}
|
||||||
|
Dewpoint (F): {{.Data.DewpointF}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.WindSpeedMph}}
|
||||||
|
Wind Speed (mph): {{.Data.WindSpeedMph}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.RelativeHumidityPercent}}
|
||||||
|
Relative Humidity (%): {{.Data.RelativeHumidityPercent}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.WindDirectionDegrees}}
|
||||||
|
Wind Direction (deg): {{.Data.WindDirectionDegrees}}
|
||||||
|
{{- end}}
|
||||||
|
{{- else -}}
|
||||||
|
No current conditions data available.
|
||||||
|
{{- end}}
|
||||||
Reference in New Issue
Block a user