Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions nfdrs/driver.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package nfdrs

import (
"fmt"
"math"
"time"

Expand All @@ -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.
Expand Down Expand Up @@ -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
}
95 changes: 95 additions & 0 deletions nfdrs/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package nfdrs

import (
"encoding/csv"
"encoding/json"
"math"
"os"
"strconv"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions nfdrs/indices.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading