Skip to content

Commit ea6099d

Browse files
committed
isobar and lightning
1 parent 5e17621 commit ea6099d

3 files changed

Lines changed: 171 additions & 19 deletions

File tree

noaa_weather_cache.go

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func (wc *WeatherCache) serveModel(w http.ResponseWriter, r *http.Request, m *We
213213
http.Error(w, "model disabled: "+m.Reason, http.StatusNotImplemented)
214214
return
215215
}
216-
if m.Fetch == nil {
216+
if m.Fetch == nil && m.FetchBytes == nil {
217217
// Frontend-rendered model (e.g. PacIOOS WMS heatmap) — there's
218218
// no JSON to serve, the picker uses a different code path.
219219
http.Error(w, "model is frontend-rendered, no data endpoint", http.StatusNotImplemented)
@@ -335,18 +335,38 @@ func (wc *WeatherCache) refreshNow(ctx context.Context, m *WeatherModel, fh int)
335335
return nil
336336
}
337337

338-
records, err := wc.spanFetch(ctx, m, fh)
339-
if err != nil {
340-
wc.errs.Add(1)
341-
span.RecordError(err)
342-
span.SetStatus(codes.Error, "fetch")
343-
return fmt.Errorf("%s fetch: %w", m.Name, err)
344-
}
345-
span.SetAttributes(attribute.Int("weather.records", len(records)))
346-
if err := wc.spanEncode(ctx, m, fh, records); err != nil {
347-
span.RecordError(err)
348-
span.SetStatus(codes.Error, "encode")
349-
return err
338+
// Models split into two output shapes: ol-wind windRecord JSON for
339+
// the particle layers (wind / waves), and pre-serialised bytes
340+
// (GeoJSON for isobars, …) for the vector overlays. FetchBytes wins
341+
// when set so a model can short-circuit the windRecord encoder.
342+
if m.FetchBytes != nil {
343+
body, err := wc.spanFetchBytes(ctx, m, fh)
344+
if err != nil {
345+
wc.errs.Add(1)
346+
span.RecordError(err)
347+
span.SetStatus(codes.Error, "fetch")
348+
return fmt.Errorf("%s fetch: %w", m.Name, err)
349+
}
350+
span.SetAttributes(attribute.Int("weather.bytes_in", len(body)))
351+
if err := wc.spanWriteBytes(ctx, m, fh, body); err != nil {
352+
span.RecordError(err)
353+
span.SetStatus(codes.Error, "encode")
354+
return err
355+
}
356+
} else {
357+
records, err := wc.spanFetch(ctx, m, fh)
358+
if err != nil {
359+
wc.errs.Add(1)
360+
span.RecordError(err)
361+
span.SetStatus(codes.Error, "fetch")
362+
return fmt.Errorf("%s fetch: %w", m.Name, err)
363+
}
364+
span.SetAttributes(attribute.Int("weather.records", len(records)))
365+
if err := wc.spanEncode(ctx, m, fh, records); err != nil {
366+
span.RecordError(err)
367+
span.SetStatus(codes.Error, "encode")
368+
return err
369+
}
350370
}
351371
// Write the .gz sibling so requests that advertise gzip can skip the
352372
// 35 MB raw transfer. Failures here are non-fatal — the handler
@@ -356,7 +376,7 @@ func (wc *WeatherCache) refreshNow(ctx context.Context, m *WeatherModel, fh int)
356376
span.RecordError(err)
357377
}
358378
wc.refreshes.Add(1)
359-
wc.logger.Infof("weather: refreshed %s fh=%d records=%d", m.Name, fh, len(records))
379+
wc.logger.Infof("weather: refreshed %s fh=%d", m.Name, fh)
360380
return nil
361381
}
362382

@@ -419,6 +439,48 @@ func (wc *WeatherCache) spanEncode(ctx context.Context, m *WeatherModel, fh int,
419439
return nil
420440
}
421441

442+
// spanFetchBytes mirrors spanFetch but for the FetchBytes path used by
443+
// vector overlays (isobars). The model returns already-serialised bytes
444+
// (GeoJSON) so the cache layer doesn't go through windRecord encoding.
445+
func (wc *WeatherCache) spanFetchBytes(ctx context.Context, m *WeatherModel, fh int) ([]byte, error) {
446+
ctx, span := tracer().Start(ctx, "weather.fetch",
447+
trace.WithAttributes(
448+
attribute.String("weather.model", m.Name),
449+
attribute.Int("weather.fh", fh),
450+
),
451+
)
452+
defer span.End()
453+
body, err := m.FetchBytes(ctx, wc.client, time.Time{}, fh)
454+
if err != nil {
455+
span.RecordError(err)
456+
span.SetStatus(codes.Error, err.Error())
457+
}
458+
return body, err
459+
}
460+
461+
// spanWriteBytes is the FetchBytes equivalent of spanEncode: writes the
462+
// raw bytes atomically (.tmp + rename) into the model's cache file.
463+
func (wc *WeatherCache) spanWriteBytes(ctx context.Context, m *WeatherModel, fh int, body []byte) error {
464+
_, span := tracer().Start(ctx, "weather.encode",
465+
trace.WithAttributes(
466+
attribute.String("weather.model", m.Name),
467+
attribute.Int("weather.fh", fh),
468+
),
469+
)
470+
defer span.End()
471+
tmp := wc.cachePath(m.Name, fh) + ".tmp"
472+
if err := os.WriteFile(tmp, body, 0o644); err != nil {
473+
span.RecordError(err)
474+
return err
475+
}
476+
if err := os.Rename(tmp, wc.cachePath(m.Name, fh)); err != nil {
477+
span.RecordError(err)
478+
return err
479+
}
480+
span.SetAttributes(attribute.Int64("weather.bytes", int64(len(body))))
481+
return nil
482+
}
483+
422484
// spanWriteGzip wraps writeGzip with a span recording the compressed
423485
// size so traces show the wire-savings vs. the raw .json size.
424486
func (wc *WeatherCache) spanWriteGzip(ctx context.Context, modelName string, fh int) error {
@@ -513,7 +575,7 @@ func (wc *WeatherCache) runPrewarm(ctx context.Context) {
513575
)
514576
}()
515577
for _, m := range listModels() {
516-
if m.Disabled || m.Fetch == nil {
578+
if m.Disabled || (m.Fetch == nil && m.FetchBytes == nil) {
517579
continue
518580
}
519581
step := m.StepFh

src/marineMap.svelte

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
colorForValue,
2121
type WeatherLayerHandle,
2222
} from "./lib/windLayer";
23+
import {
24+
setupIsobarLayer,
25+
type IsobarLayerHandle,
26+
} from "./lib/isobarLayer";
2327
import "ol/ol.css";
2428
import ScaleLine from "ol/control/ScaleLine.js";
2529
import { defaults as defaultControls } from "ol/control/defaults.js";
@@ -76,6 +80,11 @@
7680
// chart and the cursor wind/wave readout.
7781
let windHandle: WeatherLayerHandle | null = $state(null);
7882
let waveHandle: WeatherLayerHandle | null = $state(null);
83+
// Isobar layer (GFS PRMSL contours, GeoJSON LineStrings). Same
84+
// forecast-hour slider as the wind/wave layers, but no model picker —
85+
// there's only one PRMSL source registered today.
86+
let isobarHandle: IsobarLayerHandle | null = $state(null);
87+
let isobarLoading = $state(true);
7988
// Constants used by the wind/wave legend UIs. ol-wind's Field
8089
// carries the magnitude slot — wind speed in m/s for the wind layer,
8190
// wave height in m for the wave layer (we encoded h·sin/h·cos as u/v
@@ -98,7 +107,7 @@
98107
// the forecast bar covers both initial load and subsequent fetches.
99108
let windLoading = $state(true);
100109
let waveLoading = $state(true);
101-
let weatherLoading = $derived(windLoading || waveLoading);
110+
let weatherLoading = $derived(windLoading || waveLoading || isobarLoading);
102111
// First forecast hour ≥ "now", computed from the GFS run time. Used
103112
// as the slider minimum so the user can't scrub back into already-
104113
// expired analysis hours.
@@ -119,7 +128,7 @@
119128
type WeatherModelMeta = {
120129
name: string;
121130
displayName: string;
122-
kind: "wind" | "wave";
131+
kind: "wind" | "wave" | "isobars" | "lightning";
123132
domain: string;
124133
minFh: number;
125134
maxFh: number;
@@ -3003,6 +3012,27 @@
30033012
on: false,
30043013
maxZoom: weatherMaxZoom,
30053014
});
3015+
// Isobar overlay (GFS PRMSL contours). Placeholder row so the
3016+
// panel order matches wind/waves; setupIsobarLayer fills in the
3017+
// .layer reference once the first GeoJSON fetch resolves.
3018+
mapGlobal.layerOptions.push({
3019+
name: "isobars",
3020+
displayName: "isobars",
3021+
parent: "weather",
3022+
on: false,
3023+
maxZoom: weatherMaxZoom,
3024+
});
3025+
// Lightning overlay. Backed by the noaa-glm stub model server-
3026+
// side — the option appears in the panel but turning it on
3027+
// surfaces the "needs NetCDF decoder" reason. Filled in for real
3028+
// when the GLM decoder ships.
3029+
mapGlobal.layerOptions.push({
3030+
name: "lightning",
3031+
displayName: "lightning",
3032+
parent: "weather",
3033+
on: false,
3034+
maxZoom: weatherMaxZoom,
3035+
});
30063036
const ensureRendered = () => {
30073037
if (mapGlobal.map) {
30083038
mapGlobal.map.render();
@@ -3097,6 +3127,31 @@
30973127
.finally(() => {
30983128
waveLoading = false;
30993129
});
3130+
// Isobar overlay (GFS PRMSL contours). Same backend cache as
3131+
// wind/waves, but the model returns GeoJSON LineStrings instead
3132+
// of ol-wind records — handled by setupIsobarLayer with a thin
3133+
// OL Vector layer. Forecast-hour scrubs are driven by the same
3134+
// slider via the lockstep handler further down.
3135+
setupIsobarLayer(mapGlobal, {
3136+
layerName: "isobars",
3137+
displayName: "isobars",
3138+
parent: "weather",
3139+
model: "gfs-isobars",
3140+
initialForecastHour: initialFh,
3141+
maxZoom: weatherMaxZoom,
3142+
})
3143+
.then((handle) => {
3144+
isobarHandle = handle;
3145+
if (handle && weatherForecastHour > 0) {
3146+
handle.setForecastHour(weatherForecastHour).catch(() => {});
3147+
}
3148+
})
3149+
.catch((err) => {
3150+
console.warn("isobar layer disabled:", err);
3151+
})
3152+
.finally(() => {
3153+
isobarLoading = false;
3154+
});
31003155
// Populate the model picker. Failures are non-fatal — the picker
31013156
// just stays at the bundled defaults (GFS + PacIOOS) if the
31023157
// registry endpoint isn't reachable for some reason.
@@ -5011,6 +5066,7 @@
50115066
weatherForecastHour = v;
50125067
if (windHandle) windLoading = true;
50135068
if (waveHandle) waveLoading = true;
5069+
if (isobarHandle) isobarLoading = true;
50145070
// Hide both data layers while we refetch so the user doesn't
50155071
// see the previous forecast hour's pixels under the new
50165072
// forecast-hour label — purely visual, the OL layers stay
@@ -5021,14 +5077,20 @@
50215077
const waveLayer = mapGlobal.layerOptions.find(
50225078
(l) => l.name === "waves",
50235079
)?.layer as any;
5080+
const isobarLayer = mapGlobal.layerOptions.find(
5081+
(l) => l.name === "isobars",
5082+
)?.layer as any;
50245083
windLayer?.setVisible?.(false);
50255084
waveLayer?.setVisible?.(false);
5085+
isobarLayer?.setVisible?.(false);
50265086
try {
5027-
// Drive both layers in lockstep — both are now ol-wind
5028-
// pipelines fed by /noaa-weather/{gfs,wave}/latest.json?fh=N.
5087+
// Drive all weather layers in lockstep — all three are fed
5088+
// by the same /noaa-weather/data/{model}/latest.json?fh=N
5089+
// pipeline so they refresh together when the slider moves.
50295090
const tasks: Promise<void>[] = [];
50305091
if (windHandle) tasks.push(windHandle.setForecastHour(v));
50315092
if (waveHandle) tasks.push(waveHandle.setForecastHour(v));
5093+
if (isobarHandle) tasks.push(isobarHandle.setForecastHour(v));
50325094
await Promise.all(tasks);
50335095
if (windHandle) {
50345096
weatherForecastHour = windHandle.getForecastHour();
@@ -5042,8 +5104,10 @@
50425104
} finally {
50435105
windLoading = false;
50445106
waveLoading = false;
5107+
isobarLoading = false;
50455108
windLayer?.setVisible?.(true);
50465109
waveLayer?.setVisible?.(true);
5110+
isobarLayer?.setVisible?.(true);
50475111
}
50485112
}}
50495113
/>

weather_models.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ type WeatherModel struct {
4040
// distinctly so a deploy can tell "upstream missing" from "decoder
4141
// missing".
4242
Fetch func(ctx context.Context, client *http.Client, runTime time.Time, fh int) ([]windRecord, error)
43+
// FetchBytes is the alternative path for models whose on-the-wire
44+
// shape isn't the ol-wind 2-record JSON — currently isobars (GeoJSON
45+
// LineString FeatureCollection) and (eventually) lightning strokes.
46+
// When set, the cache writes the returned bytes straight to disk
47+
// instead of json-encoding []windRecord. Exactly one of Fetch /
48+
// FetchBytes must be set on an enabled model.
49+
FetchBytes func(ctx context.Context, client *http.Client, runTime time.Time, fh int) ([]byte, error)
4350
}
4451

4552
// snapFh snaps fh to [MinFh, MaxFh] aligned to StepFh.
@@ -67,6 +74,8 @@ var allModels = []*WeatherModel{
6774
iconGlobalWindStub(),
6875
pacioosWaveModel(),
6976
pacioosHawaiiWaveModel(),
77+
gfsIsobarsModel(),
78+
noaaGLMLightningStub(),
7079
// nomads-gfswave is unregistered until we verify NOMADS' actual
7180
// current DODS URL pattern. Every variant I've tried (date dir
7281
// with/without `gfswave` prefix, gfsv16 suffix, etc.) lands on
@@ -595,6 +604,23 @@ func iconGlobalWindStub() *WeatherModel {
595604
}
596605
}
597606

607+
// noaaGLMLightningStub registers the GOES-East GLM lightning option in
608+
// the picker so it surfaces in the layer panel under the weather
609+
// group, but flips Disabled so toggling it on shows the Reason
610+
// (matching the NAM/ECMWF/ICON pattern). The actual GLM L2 LCFA feed
611+
// is NetCDF4/HDF5 over the NESDIS S3 mirror — decoding that is a
612+
// separate work item.
613+
func noaaGLMLightningStub() *WeatherModel {
614+
return &WeatherModel{
615+
Name: "noaa-glm",
616+
DisplayName: "Lightning (GOES-East GLM, near-real-time)",
617+
Kind: "lightning",
618+
Domain: "americas",
619+
Disabled: true,
620+
Reason: "needs GOES-16/18 GLM NetCDF4 decoder (L2 LCFA strokes)",
621+
}
622+
}
623+
598624
// ----------------------------------------------------------------------
599625
// JSON shape served by /noaa-weather/models
600626
// ----------------------------------------------------------------------

0 commit comments

Comments
 (0)