From 4090360cc45ba65c78e90dbf897ae86f20efb472 Mon Sep 17 00:00:00 2001 From: "Michael Peters Jr." Date: Sun, 2 Aug 2026 16:55:11 -0700 Subject: [PATCH] feat(nfdrs): add Driver state persistence Add the DriverState type and the State and SetState methods. DriverState holds the state of the four Nelson sticks, the two live fuel moisture models, the drought index, and the driver's daily accumulators and last outputs. It marshals to JSON and back with an exact round trip. A caller can now snapshot a warm driver and restore it, so a restart does not repeat the spin-up that the 1000-hour stick needs from a cold start. The caller constructs the driver with the same Config, then restores the state. SetState checks the schema version and that every sub-model is present. Add JSON tags to the Indices type, so the stored state has stable keys. Co-Authored-By: Claude Opus 4.8 --- nfdrs/driver.go | 121 +++++++++++++++++++++++++++++++++++++++++++ nfdrs/driver_test.go | 95 +++++++++++++++++++++++++++++++++ nfdrs/indices.go | 8 +-- 3 files changed, 220 insertions(+), 4 deletions(-) diff --git a/nfdrs/driver.go b/nfdrs/driver.go index a5a133e..48f99cf 100644 --- a/nfdrs/driver.go +++ b/nfdrs/driver.go @@ -1,6 +1,7 @@ package nfdrs import ( + "fmt" "math" "time" @@ -10,6 +11,10 @@ import ( "alpineworks.io/firewx/simple" ) +// driverSchemaVersion is the schema version stamped into a DriverState. Increase +// it when the meaning of a field changes. +const driverSchemaVersion = 1 + // Config holds the site settings for a Driver. type Config struct { // FuelModel is the fuel model of the site. @@ -216,3 +221,119 @@ func (d *Driver) Indices() Indices { return d.indices } func (d *Driver) DeadMoistures() (mc1, mc10, mc100, mc1000 firewx.Percent) { return firewx.Percent(d.mc1), firewx.Percent(d.mc10), firewx.Percent(d.mc100), firewx.Percent(d.mc1000) } + +// DriverState is the full serializable state of a Driver. It holds the state of +// the four Nelson sticks, the two live fuel moisture models, the drought index, +// and the driver's own daily accumulators and last outputs. +// +// A DriverState marshals to JSON and back with an exact round trip, so a caller +// can persist a Driver between runs and resume without the long spin-up that the +// 1000-hour stick needs from a cold start. The caller must construct the Driver +// with the same Config, then restore the state. +// +// The sub-model pointers alias the Driver's live models. Marshal the state +// before the next call to Update. +type DriverState struct { + SchemaVersion int `json:"schema_version"` + + Stick1 *nelson.Stick `json:"stick_1hr"` + Stick10 *nelson.Stick `json:"stick_10hr"` + Stick100 *nelson.Stick `json:"stick_100hr"` + Stick1000 *nelson.Stick `json:"stick_1000hr"` + Herb *gsi.Model `json:"herb"` + Woody *gsi.Model `json:"woody"` + KBDI simple.KBDIState `json:"kbdi"` + + // Dead fuel moisture and fuel temperature from the most recent update. + MC1 float64 `json:"mc_1hr"` + MC10 float64 `json:"mc_10hr"` + MC100 float64 `json:"mc_100hr"` + MC1000 float64 `json:"mc_1000hr"` + FuelTemp float64 `json:"fuel_temp_c"` + + // Live fuel moisture and drought from the most recent daily update. + GSIValue float64 `json:"gsi"` + MCHerb float64 `json:"mc_herb"` + MCWood float64 `json:"mc_woody"` + KBDIValue float64 `json:"kbdi_value"` + + // Daily accumulators over the current local standard day. + HaveDay bool `json:"have_day"` + DayMinTemp float64 `json:"day_min_temp_f"` + DayMaxTemp float64 `json:"day_max_temp_f"` + DayMinRH float64 `json:"day_min_rh"` + DayPrecip float64 `json:"day_precip_in"` + + Last time.Time `json:"last"` + HaveLast bool `json:"have_last"` + Indices Indices `json:"indices"` +} + +// State returns the full state of the Driver. Marshal it to JSON to persist the +// Driver between runs. +func (d *Driver) State() DriverState { + return DriverState{ + SchemaVersion: driverSchemaVersion, + Stick1: d.stick1, + Stick10: d.stick10, + Stick100: d.stick100, + Stick1000: d.stick1000, + Herb: d.herb, + Woody: d.woody, + KBDI: d.kbdi, + MC1: d.mc1, + MC10: d.mc10, + MC100: d.mc100, + MC1000: d.mc1000, + FuelTemp: float64(d.fuelTemp), + GSIValue: d.gsiValue, + MCHerb: d.mcHerb, + MCWood: d.mcWood, + KBDIValue: d.kbdiValue, + HaveDay: d.haveDay, + DayMinTemp: d.dayMinTemp, + DayMaxTemp: d.dayMaxTemp, + DayMinRH: d.dayMinRH, + DayPrecip: float64(d.dayPrecip), + Last: d.last, + HaveLast: d.haveLast, + Indices: d.indices, + } +} + +// SetState restores the Driver from a saved state. The Driver keeps its Config, +// so construct it with the same Config first. SetState returns an error if the +// schema version does not match or a sub-model is absent. +func (d *Driver) SetState(s DriverState) error { + if s.SchemaVersion != driverSchemaVersion { + return fmt.Errorf("nfdrs: driver state schema version %d, want %d", s.SchemaVersion, driverSchemaVersion) + } + if s.Stick1 == nil || s.Stick10 == nil || s.Stick100 == nil || s.Stick1000 == nil || s.Herb == nil || s.Woody == nil { + return fmt.Errorf("nfdrs: driver state has an absent sub-model") + } + d.stick1 = s.Stick1 + d.stick10 = s.Stick10 + d.stick100 = s.Stick100 + d.stick1000 = s.Stick1000 + d.herb = s.Herb + d.woody = s.Woody + d.kbdi = s.KBDI + d.mc1 = s.MC1 + d.mc10 = s.MC10 + d.mc100 = s.MC100 + d.mc1000 = s.MC1000 + d.fuelTemp = firewx.Celsius(s.FuelTemp) + d.gsiValue = s.GSIValue + d.mcHerb = s.MCHerb + d.mcWood = s.MCWood + d.kbdiValue = s.KBDIValue + d.haveDay = s.HaveDay + d.dayMinTemp = s.DayMinTemp + d.dayMaxTemp = s.DayMaxTemp + d.dayMinRH = s.DayMinRH + d.dayPrecip = firewx.Inches(s.DayPrecip) + d.last = s.Last + d.haveLast = s.HaveLast + d.indices = s.Indices + return nil +} diff --git a/nfdrs/driver_test.go b/nfdrs/driver_test.go index 865e818..d7592c2 100644 --- a/nfdrs/driver_test.go +++ b/nfdrs/driver_test.go @@ -2,6 +2,7 @@ package nfdrs import ( "encoding/csv" + "encoding/json" "math" "os" "strconv" @@ -111,6 +112,100 @@ func TestDriverRejectsIncompleteObs(t *testing.T) { } } +// TestDriverStateRoundTrip checks that a driver marshals to JSON and back with +// an exact round trip. A restored driver produces identical output for the same +// further weather, so a caller can persist the driver and resume without the +// spin-up. +func TestDriverStateRoundTrip(t *testing.T) { + cfg := Config{ + FuelModel: FuelModelY, Latitude: 47.7, SlopeClass: 1, KBDIThreshold: 800, + MeanAnnualPrecip: 40, AnnualHerb: true, RegObsHour: 13, LSTOffset: -8 * time.Hour, + } + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + mkObs := func(i int) firewx.Obs { + fi := float64(i) + rain := 0.0 + if i%17 == 0 { + rain = 3.0 + } + return firewx.Obs{ + Time: base.Add(time.Duration(i) * time.Hour), + Temperature: firewx.Some(firewx.Celsius(15 + 10*math.Sin(fi/6))), + RelativeHumidity: firewx.Some(firewx.Percent(40 + 30*math.Cos(fi/5))), + SolarRadiation: firewx.Some(firewx.WattsPerSquareMeter(400 * math.Abs(math.Sin(fi/12)))), + Precipitation: firewx.Some(firewx.Millimeters(rain)), + WindSpeed: firewx.Some(firewx.MetersPerSecond(3)), + } + } + + // Run the driver across two daily boundaries so the daily state is set. + orig := NewDriver(cfg) + for i := range 60 { + orig.Update(mkObs(i)) + } + + blob, err := json.Marshal(orig.State()) + if err != nil { + t.Fatal(err) + } + var st DriverState + if err := json.Unmarshal(blob, &st); err != nil { + t.Fatal(err) + } + restored := NewDriver(cfg) + if err := restored.SetState(st); err != nil { + t.Fatal(err) + } + + // A second marshal after the restore is byte-identical. + blob2, err := json.Marshal(restored.State()) + if err != nil { + t.Fatal(err) + } + if string(blob) != string(blob2) { + t.Fatalf("re-marshalled driver state differs from the original") + } + + // Drive both with the same further weather. The output must stay identical. + for i := 60; i < 96; i++ { + o := mkObs(i) + orig.Update(o) + restored.Update(o) + if orig.Indices() != restored.Indices() { + t.Fatalf("step %d indices: original %+v, restored %+v", i, orig.Indices(), restored.Indices()) + } + a1, a10, a100, a1000 := orig.DeadMoistures() + b1, b10, b100, b1000 := restored.DeadMoistures() + if a1 != b1 || a10 != b10 || a100 != b100 || a1000 != b1000 { + t.Fatalf("step %d dead moistures differ", i) + } + } +} + +// TestDriverSetStateErrors checks that SetState rejects a wrong schema version +// and an absent sub-model. +func TestDriverSetStateErrors(t *testing.T) { + cfg := Config{FuelModel: FuelModelY, SlopeClass: 1, KBDIThreshold: 800, RegObsHour: 13} + d := NewDriver(cfg) + good := NewDriver(cfg).State() + + wrongVersion := good + wrongVersion.SchemaVersion = good.SchemaVersion + 1 + if d.SetState(wrongVersion) == nil { + t.Errorf("expected an error for a wrong schema version") + } + + absentModel := good + absentModel.Stick10 = nil + if d.SetState(absentModel) == nil { + t.Errorf("expected an error for an absent sub-model") + } + + if err := d.SetState(good); err != nil { + t.Errorf("unexpected error for a valid state: %v", err) + } +} + type hourly struct { obs firewx.Obs mc10, mc100, mc1000 float64 diff --git a/nfdrs/indices.go b/nfdrs/indices.go index 835028d..a836b49 100644 --- a/nfdrs/indices.go +++ b/nfdrs/indices.go @@ -20,16 +20,16 @@ const ( type Indices struct { // SpreadComponent is the forward rate of spread of the head fire, related // to feet per minute. - SpreadComponent float64 + SpreadComponent float64 `json:"spread_component"` // EnergyReleaseComponent is the energy release per unit area of the flaming // front. - EnergyReleaseComponent float64 + EnergyReleaseComponent float64 `json:"energy_release_component"` // BurningIndex combines the spread component and the energy release // component. - BurningIndex float64 + BurningIndex float64 `json:"burning_index"` // IgnitionComponent is the chance that a firebrand starts a fire, from 0 to // 100. - IgnitionComponent float64 + IgnitionComponent float64 `json:"ignition_component"` } // Conditions holds the fuel moisture and the weather for an index computation.