|
| 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