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.