Skip to content

Commit ecb38e9

Browse files
committed
isobar
1 parent f5be859 commit ecb38e9

3 files changed

Lines changed: 747 additions & 0 deletions

File tree

noaa_isobars.go

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package vc
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"math"
8+
"net/http"
9+
"time"
10+
)
11+
12+
// GFS PRMSL isobar overlay. Fetches the PRMSL (Pressure Reduced to Mean
13+
// Sea Level) field from NOMADS at the same 0.25° grid the existing wind
14+
// fetch uses, runs marching squares at 4 hPa intervals, and returns a
15+
// GeoJSON FeatureCollection of LineString features keyed by pressure
16+
// level (in hPa). Frontend renders as a thin OL Vector layer next to
17+
// the wind / waves overlays.
18+
//
19+
// We piggyback on the wind cache pipeline (same disk cache, same gzip
20+
// sibling, same forecast-hour slider) via the FetchBytes branch on
21+
// WeatherModel — no separate handler / cache files / stats path.
22+
23+
const (
24+
// NOMADS GFS PRMSL filter — same template as wind but with the
25+
// PRMSL variable + MSL level instead of UGRD/VGRD@10m. ~150 KB
26+
// GRIB2 per hour, decoded to a 1.5-3 MB GeoJSON.
27+
nomadsGFSPRMSLURLTemplate = "https://nomads.ncep.noaa.gov/cgi-bin/filter_gfs_0p25.pl" +
28+
"?file=gfs.t%02dz.pgrb2.0p25.f%03d" +
29+
"&lev_mean_sea_level=on&var_PRMSL=on" +
30+
"&dir=%%2Fgfs.%s%%2F%02d%%2Fatmos"
31+
32+
// GRIB2 product identification for PRMSL: discipline 0
33+
// (Meteorological), category 3 (Mass), number 1 (PRMSL), surface
34+
// 101 (Mean Sea Level). See WMO Manual on Codes, Table 4.2-0-3.
35+
gribParamCatMass = 3
36+
gribParamPRMSL = 1
37+
gribSurfaceMeanSeaLevel = 101
38+
39+
// Isobar contour spacing in hectopascals. 4 hPa is the standard
40+
// marine forecast spacing (NWS surface analysis charts). Range
41+
// covers anything from a very deep low (~920 hPa, hurricane core)
42+
// to a strong high (~1060 hPa).
43+
isobarStepHPa = 4
44+
isobarMinHPa = 920
45+
isobarMaxHPa = 1064
46+
)
47+
48+
func gfsIsobarsModel() *WeatherModel {
49+
m := &WeatherModel{
50+
Name: "gfs-isobars",
51+
DisplayName: "Isobars (GFS PRMSL, 0.25° global)",
52+
Kind: "isobars",
53+
Domain: "global",
54+
CycleHours: []int{0, 6, 12, 18},
55+
MinFh: 0,
56+
MaxFh: 240,
57+
StepFh: 3,
58+
PublishLagH: 4,
59+
}
60+
m.FetchBytes = func(ctx context.Context, client *http.Client, _ time.Time, fh int) ([]byte, error) {
61+
body, runT, err := walkLatestCycle(ctx, m, fh, func(ctx context.Context, t time.Time) ([]byte, error) {
62+
date := t.Format("20060102")
63+
cc := t.Hour()
64+
url := fmt.Sprintf(nomadsGFSPRMSLURLTemplate, cc, fh, date, cc)
65+
return fetchURL(ctx, client, url)
66+
})
67+
if err != nil {
68+
return nil, err
69+
}
70+
return decodeGFSIsobars(body, runT, fh)
71+
}
72+
return m
73+
}
74+
75+
// decodeGFSIsobars parses the PRMSL GRIB2 message, runs marching squares
76+
// at 4 hPa intervals, and emits a GeoJSON FeatureCollection. The header
77+
// section (refTime, forecastTime) rides along on the FeatureCollection
78+
// as a top-level `meta` key so the frontend can show "valid: <date>".
79+
func decodeGFSIsobars(grib []byte, runTime time.Time, fh int) ([]byte, error) {
80+
wantPRMSL := func(discipline, paramCat, paramNum, surfType int, surfValue float64) bool {
81+
return paramCat == gribParamCatMass &&
82+
paramNum == gribParamPRMSL &&
83+
surfType == gribSurfaceMeanSeaLevel
84+
}
85+
rec, err := decodeRegularLLMessage(grib, runTime, fh, wantPRMSL)
86+
if err != nil {
87+
return nil, fmt.Errorf("PRMSL decode: %w", err)
88+
}
89+
if rec == nil {
90+
return nil, fmt.Errorf("PRMSL not found in GRIB body")
91+
}
92+
features := contourLatLonGrid(rec)
93+
fc := geoJSONFeatureCollection{
94+
Type: "FeatureCollection",
95+
Features: features,
96+
Meta: &isobarMeta{
97+
RefTime: rec.Header.RefTime,
98+
ForecastTime: rec.Header.ForecastTime,
99+
StepHPa: isobarStepHPa,
100+
},
101+
}
102+
return json.Marshal(fc)
103+
}
104+
105+
// --- GeoJSON shape --------------------------------------------------------
106+
107+
type geoJSONFeatureCollection struct {
108+
Type string `json:"type"`
109+
Features []geoJSONFeature `json:"features"`
110+
Meta *isobarMeta `json:"meta,omitempty"`
111+
}
112+
113+
type geoJSONFeature struct {
114+
Type string `json:"type"`
115+
Geometry geoJSONGeometry `json:"geometry"`
116+
Properties isobarProperties `json:"properties"`
117+
}
118+
119+
type geoJSONGeometry struct {
120+
Type string `json:"type"`
121+
Coordinates [][2]float64 `json:"coordinates"`
122+
}
123+
124+
type isobarProperties struct {
125+
HPa int `json:"hPa"`
126+
}
127+
128+
type isobarMeta struct {
129+
RefTime string `json:"refTime"`
130+
ForecastTime int `json:"forecastTime"`
131+
StepHPa int `json:"stepHPa"`
132+
}
133+
134+
// --- Marching squares -----------------------------------------------------
135+
136+
// contourLatLonGrid walks every grid cell of `rec` and emits one
137+
// LineString feature per (cell, level) crossing. We don't stitch
138+
// adjacent segments into longer polylines — disjoint segments render
139+
// identically in OpenLayers, and stitching adds ~200 lines of code for
140+
// marginal wire-savings. The frontend can group by hPa for labelling.
141+
//
142+
// Grid layout: data is stored row-major north-to-south (GFS scan mode
143+
// 0). Index `iy*Nx + ix` gives lat=La1 - iy*Dy, lon=Lo1 + ix*Dx (with
144+
// Lo1 wrapping at 360°). PRMSL values are in Pa.
145+
func contourLatLonGrid(rec *windRecord) []geoJSONFeature {
146+
h := rec.Header
147+
nx, ny := h.Nx, h.Ny
148+
if nx < 2 || ny < 2 || len(rec.Data) < nx*ny {
149+
return nil
150+
}
151+
// NOMADS publishes GFS with Lo1=0, scan W→E across [0, 360). We
152+
// emit features in [-180, 180]. The shift is per-cell rather than
153+
// per-segment-endpoint: any cell whose left edge sits at or beyond
154+
// 180° gets its entire cell shifted west by 360°, so interpolated
155+
// contour points all land in the same hemisphere. Per-endpoint
156+
// wrap is wrong here — the cell at ix where lonL=180 produces
157+
// segments with one endpoint sitting exactly on 180 and the other
158+
// just past it, which a per-point wrap would turn into a 360°-wide
159+
// horizontal stripe across the chart.
160+
shiftLon := func(l float64) float64 {
161+
if l >= 180 {
162+
return l - 360
163+
}
164+
return l
165+
}
166+
// Build a level list once — Pa values to match the data units.
167+
levels := make([]float64, 0, (isobarMaxHPa-isobarMinHPa)/isobarStepHPa+1)
168+
for hpa := isobarMinHPa; hpa <= isobarMaxHPa; hpa += isobarStepHPa {
169+
levels = append(levels, float64(hpa)*100)
170+
}
171+
172+
// Pre-size: typical GFS frame produces ~15-25k segments globally.
173+
out := make([]geoJSONFeature, 0, 20000)
174+
for iy := 0; iy < ny-1; iy++ {
175+
// Latitudes of the top and bottom edges of this cell row. La1
176+
// is the northern edge for GFS scan mode 0; rows advance south.
177+
latT := h.La1 - float64(iy)*h.Dy
178+
latB := h.La1 - float64(iy+1)*h.Dy
179+
for ix := 0; ix < nx-1; ix++ {
180+
// Cell corners — labelled like the MS literature:
181+
// tl ── tr
182+
// │ │
183+
// bl ── br
184+
tl := rec.Data[iy*nx+ix]
185+
tr := rec.Data[iy*nx+ix+1]
186+
bl := rec.Data[(iy+1)*nx+ix]
187+
br := rec.Data[(iy+1)*nx+ix+1]
188+
// Skip cells with any sentinel / NaN value.
189+
if !valid(tl) || !valid(tr) || !valid(bl) || !valid(br) {
190+
continue
191+
}
192+
lonL := shiftLon(h.Lo1 + float64(ix)*h.Dx)
193+
lonR := lonL + h.Dx
194+
// Cell-level min/max — cheap reject of cells with no
195+
// contour crossings before the level loop.
196+
cellMin := tl
197+
cellMax := tl
198+
for _, v := range [3]float64{tr, bl, br} {
199+
if v < cellMin {
200+
cellMin = v
201+
}
202+
if v > cellMax {
203+
cellMax = v
204+
}
205+
}
206+
for li, level := range levels {
207+
if level < cellMin || level > cellMax {
208+
continue
209+
}
210+
segs := marchCell(level, tl, tr, bl, br, lonL, lonR, latT, latB)
211+
for _, s := range segs {
212+
// Defense in depth against wrap regressions: any
213+
// segment wider than one cell (~Dx + small slack) or
214+
// taller than one cell is structurally impossible
215+
// here, so it's the marching-squares output of a
216+
// shift/interp bug. Dropping it keeps a future
217+
// regression from repainting horizontal stripes
218+
// across the chart.
219+
if math.Abs(s[0]-s[2]) > h.Dx*2 || math.Abs(s[1]-s[3]) > h.Dy*2 {
220+
continue
221+
}
222+
out = append(out, geoJSONFeature{
223+
Type: "Feature",
224+
Geometry: geoJSONGeometry{
225+
Type: "LineString",
226+
Coordinates: [][2]float64{
227+
{s[0], s[1]},
228+
{s[2], s[3]},
229+
},
230+
},
231+
Properties: isobarProperties{
232+
HPa: isobarMinHPa + li*isobarStepHPa,
233+
},
234+
})
235+
}
236+
}
237+
}
238+
}
239+
return out
240+
}
241+
242+
// valid screens out the GRIB2 missing-value sentinel and NaN. PRMSL
243+
// itself doesn't carry a bitmap, but we treat anything wildly outside
244+
// the realisable range (~870-1085 hPa) as missing too.
245+
func valid(v float64) bool {
246+
if math.IsNaN(v) {
247+
return false
248+
}
249+
if v < 70000 || v > 110000 {
250+
return false
251+
}
252+
return true
253+
}
254+
255+
// marchCell returns 0, 1, or 2 segments through one cell where the
256+
// scalar field crosses `level`. Each segment is encoded as
257+
// [lon0, lat0, lon1, lat1].
258+
//
259+
// Standard marching-squares case index, with tl=8, tr=4, br=2, bl=1
260+
// summed into a 4-bit number. Cases 0 and 15 emit nothing; 5 and 10
261+
// are the "saddle" cases — we resolve by the cell's mean (the standard
262+
// disambiguation, no asymptotic decider needed for pressure fields
263+
// which are smooth at our spacing).
264+
func marchCell(level, tl, tr, bl, br, lonL, lonR, latT, latB float64) [][4]float64 {
265+
idx := 0
266+
if tl > level {
267+
idx |= 8
268+
}
269+
if tr > level {
270+
idx |= 4
271+
}
272+
if br > level {
273+
idx |= 2
274+
}
275+
if bl > level {
276+
idx |= 1
277+
}
278+
if idx == 0 || idx == 15 {
279+
return nil
280+
}
281+
// Linear interpolation along each cell edge for the contour
282+
// crossing point. Edge labels: T = top (between tl, tr), R = right
283+
// (tr, br), B = bottom (bl, br), L = left (tl, bl).
284+
t := func(a, b float64) float64 { return (level - a) / (b - a) }
285+
edgeT := [2]float64{lonL + t(tl, tr)*(lonR-lonL), latT}
286+
edgeR := [2]float64{lonR, latT - t(tr, br)*(latT-latB)}
287+
edgeB := [2]float64{lonL + t(bl, br)*(lonR-lonL), latB}
288+
edgeL := [2]float64{lonL, latT - t(tl, bl)*(latT-latB)}
289+
seg := func(a, b [2]float64) [4]float64 {
290+
return [4]float64{a[0], a[1], b[0], b[1]}
291+
}
292+
switch idx {
293+
case 1, 14:
294+
return [][4]float64{seg(edgeL, edgeB)}
295+
case 2, 13:
296+
return [][4]float64{seg(edgeB, edgeR)}
297+
case 3, 12:
298+
return [][4]float64{seg(edgeL, edgeR)}
299+
case 4, 11:
300+
return [][4]float64{seg(edgeT, edgeR)}
301+
case 6, 9:
302+
return [][4]float64{seg(edgeT, edgeB)}
303+
case 7, 8:
304+
return [][4]float64{seg(edgeT, edgeL)}
305+
case 5:
306+
// Saddle: tl + br above, tr + bl below. Resolve by cell mean
307+
// to decide which pair of arcs to connect.
308+
mean := (tl + tr + bl + br) * 0.25
309+
if mean > level {
310+
return [][4]float64{seg(edgeT, edgeL), seg(edgeB, edgeR)}
311+
}
312+
return [][4]float64{seg(edgeT, edgeR), seg(edgeB, edgeL)}
313+
case 10:
314+
// Saddle: tr + bl above, tl + br below.
315+
mean := (tl + tr + bl + br) * 0.25
316+
if mean > level {
317+
return [][4]float64{seg(edgeT, edgeR), seg(edgeB, edgeL)}
318+
}
319+
return [][4]float64{seg(edgeT, edgeL), seg(edgeB, edgeR)}
320+
}
321+
return nil
322+
}

0 commit comments

Comments
 (0)