@@ -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.
424486func (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
0 commit comments