firewx

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 5 Imported by: 0

README

firewx

Fire danger and fire weather calculations in Go.

Implements the Canadian Forest Fire Weather Index System, the United States National Fire Danger Rating System, and the single-equation indices (Fosberg, Chandler, Hot-Dry-Windy, Keetch-Byram and others).

There is no existing Go implementation of any of these. The reference implementations are C++ (firelab/NFDRS4), R (cffdrs/cffdrs_r) and Python (cffdrs/cffdrs_py, nrcan-cfs-fire/cffdrs-ng).

Status

The root module carries the shared types and the simple indices. The fwi, nfdrs, and fetch modules are implemented.

Module Import path Status
root alpineworks.io/firewx Released: types, units, and simple
simple alpineworks.io/firewx/simple Implemented (a package in root)
fwi alpineworks.io/firewx/fwi Implemented
nfdrs alpineworks.io/firewx/nfdrs Implemented: Nelson, Rothermel, GSI, and the four indices with a driver
fetch alpineworks.io/firewx/fetch Synoptic and FEMS clients done; WRCC pending

Layout

Four modules, split on dependency footprint rather than topic. A consumer who wants the Fine Fuel Moisture Code should not inherit an HTTP client and its transitive dependencies.

firewx/
├── go.work
├── go.mod                      alpineworks.io/firewx
├── units.go obs.go opt.go wind.go
├── simple/                     package, not a module
├── fwi/     go.mod             alpineworks.io/firewx/fwi
├── nfdrs/   go.mod             alpineworks.io/firewx/nfdrs
│   │                           fuel models, indices, and the driver
│   ├── nelson/                 dead fuel moisture solver
│   ├── rothermel/              spread model and size-class weighting
│   └── gsi/                    growing season index
└── fetch/   go.mod             alpineworks.io/firewx/fetch
    ├── fems/
    ├── synoptic/
    └── wrcc/

Modules are versioning units; packages are import units. nelson, rothermel and gsi always change together and have no reason to version independently, so they are packages inside nfdrs rather than modules of their own.

fwi, nfdrs and fetch all depend on the root module for shared types. The mitigation for the coordinated-bump problem this creates is to design the root's public surface once and then freeze it. If the root is churning monthly, the types are wrong.

Design decisions

Named unit types everywhere. The public API never takes a bare float64 for a measurement. The Canadian system is specified in Celsius, km/h and millimetres; NFDRS and Rothermel are specified in Fahrenheit, mph, inches and imperial fuel loadings. Observations are carried in SI and each model converts at its own boundary.

Missing data is Opt[T], not NaN. RAWS drop hours routinely and consumer stations drop them more often. NaN propagates silently through the finite-difference loop in the Nelson model and produces output that looks plausible but is not.

Local standard time is a fixed offset, never a time.Location. Both systems define their observation windows in LST, which does not shift with daylight saving. Using zoneinfo introduces a one hour discontinuity in the carryover codes twice a year that is nearly invisible in the output.

Two API layers. Pure functions for the mathematics, testable against golden vectors with no hidden state, and a stateful driver on top that carries the codes forward. Model state is a plain struct with exported fields, a schema version, and a property-tested JSON round trip, because it is persisted between daily runs and the Nelson stick's internal radial nodes must survive exactly or the model drifts.

Phasing

  1. Skeleton. Workspace, four modules, CI, Release Please, frozen root types.
  2. simple. Single-equation indices. Small enough that debugging the release pipeline here costs nothing, which is the point of doing it first.
  3. fwi. Golden vectors ported from cffdrs_r test data. Evaluate cffdrs-ng's hourly formulation before committing to the daily one.
  4. fetch. FEMS and Synoptic clients. Comes before NFDRS because it is what makes NFDRS verifiable.
  5. nfdrs. In order: nelson, rothermel, gsi, assembly. Each validated independently against published references before integration.

Validation strategy

Component Validated against
simple ClimInd R package outputs and published worked examples
fwi cffdrs_r test vectors
nelson RAWS stations carrying a physical 10-hr fuel stick sensor
rothermel BehavePlus outputs
nfdrs end to end FEMS-computed ERC for the same station and period

Build firelab/NFDRS4, run it over a year of FW21 data, and freeze the output as testdata/ golden CSV before writing any Go.

Every package must have tests that use real data. The data can be historical. Use a test case from a prior implementation of the algorithm when you can, because it is a stronger reference than a value that you calculate yourself.

Development

go work sync
go test ./...

# Verify each module in isolation, as a downstream consumer sees it.
for m in . fwi nfdrs fetch; do (cd "$m" && GOWORK=off go test ./...); done

Documentation

You must write all documentation in ASD-STE100 Simplified Technical English. This applies to the Markdown files, the Go doc comments, the changelog prose, and the pull request text. CLAUDE.md gives the rules. The older prose in this file is not compliant yet.

Releases

Release Please, manifest mode, one release per module. Tags are v1.2.3 for the root module and fwi/v1.2.3 for submodules, which is exactly the form the Go module proxy expects for submodule versions.

Commit with conventional commits. Release Please routes changes to components by file path, but scoping messages (feat(nfdrs):) makes the changelogs readable.

References

Every algorithm and every test vector cites its source. The primary references are listed here.

Canadian FWI system, NFDRS, and their components:

  • Van Wagner, C.E. 1987. Development and Structure of the Canadian Forest Fire Weather Index System. Forestry Technical Report 35.
  • Nelson, R.M. 2000. Prediction of diurnal change in 10-hr fuel stick moisture content. Canadian Journal of Forest Research 30.
  • Andrews, P.L. 2018. The Rothermel Surface Fire Spread Model and Associated Developments: A Comprehensive Explanation. RMRS-GTR-371.
  • Jolly, W.M. et al. 2024. Modernizing the US National Fire Danger Rating System (version 4). Environmental Modelling and Software.

Single-equation indices (the simple package):

  • Fosberg, M.A. 1978. Weather in wildland fire management: the fire weather index. Proceedings of the Conference on Sierra Nevada Meteorology, 1-4.
  • Chandler, C., Cheney, P., Thomas, P., Trabaud, L., Williams, D. 1983. Fire in Forestry, Volume 1: Forest Fire Behavior and Effects. Wiley.
  • Sharples, J.J., McRae, R.H.D., Weber, R.O., Gill, A.M. 2009. A simple index for assessing fire danger rating. Environmental Modelling & Software 24(6): 764-774. (Chandler and Ångström forms.)
  • Srock, A.F., Charney, J.J., Potter, B.E., Goodrick, S.L. 2018. The Hot-Dry-Windy Index: a new fire weather index. Atmosphere 9(7):279.
  • Nesterov, V.G. 1949. Combustibility of the forest and methods for its determination. Goslesbumizdat, Moscow. Modern description in Groisman, P.Y. et al. 2007, Global and Planetary Change 56(3-4):371-386.
  • Keetch, J.J., Byram, G.M. 1968. A drought index for forest fire control. Res. Pap. SE-38. USDA Forest Service, Southeastern Forest Experiment Station.
  • Alexander, M.E. 1990. Computer calculation of the Keetch-Byram Drought Index — programmers beware! Fire Management Notes 51(4):23-25. (Corrects the 8.30 constant.)

Prior implementations used as test references:

  • Ziegler, J.P. et al. 2019. firebehavioR: an R package for fire behavior and danger analysis. Fire 2(3):41. (Chandler and Ångström match this package.)
  • ClimInd R package. (Nesterov and KBDI cross-checks.)

Licence

MIT. See LICENSE.

Documentation

Overview

Package firewx provides the shared observation and unit types used by the fire danger models in this repository.

Every physical quantity has a named type. The public API never accepts a bare float64 for a measurement, because the models in this repository disagree about units in ways that are easy to get wrong and hard to notice: the Canadian FWI system is specified in degrees Celsius, km/h and millimetres, while NFDRS and the Rothermel spread model are specified in degrees Fahrenheit, mph, inches and imperial fuel loadings.

The convention is that observations are carried in SI, and each model converts at its own boundary to whatever its published equations require.

Example

Example shows the shared observation type. An observation carries SI units, and each derived value is absent when an input is absent.

package main

import (
	"fmt"

	firewx "alpineworks.io/firewx"
)

func main() {
	o := firewx.Obs{
		Temperature:      firewx.Some(firewx.Celsius(20)),
		RelativeHumidity: firewx.Some(firewx.Percent(50)),
	}

	// The dew point comes from the temperature and the humidity.
	dew := o.DewPoint().Must()

	// The vapour pressure deficit comes from the same two inputs.
	vpd := o.VaporPressureDeficit().Must()

	fmt.Printf("dew point=%.1f C\n", dew)
	fmt.Printf("VPD=%.2f kPa\n", vpd)
}
Output:
dew point=9.3 C
VPD=1.17 kPa

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Celsius

type Celsius float64

Celsius is a temperature in degrees Celsius. This is the canonical temperature unit for observations.

func DewPoint

func DewPoint(t Celsius, rh Percent) Celsius

DewPoint returns the dew point temperature for t and rh, inverting the Magnus-Tetens approximation. Results below roughly -45C or above the ambient temperature should be treated as out of range.

func (Celsius) Fahrenheit

func (c Celsius) Fahrenheit() Fahrenheit

Fahrenheit converts c to degrees Fahrenheit.

Example

ExampleCelsius_Fahrenheit shows a unit conversion. Each physical quantity has a named type, so the compiler stops a unit mistake.

package main

import (
	"fmt"

	firewx "alpineworks.io/firewx"
)

func main() {
	boiling := firewx.Celsius(100)
	fmt.Printf("%.0f F\n", boiling.Fahrenheit())
}
Output:
212 F

func (Celsius) Kelvin

func (c Celsius) Kelvin() float64

Kelvin returns c as an absolute temperature. Used by the radiative terms of the Nelson dead fuel moisture model.

type Degrees

type Degrees float64

Degrees is a compass bearing, 0-360, measured clockwise from true north.

type Fahrenheit

type Fahrenheit float64

Fahrenheit is a temperature in degrees Fahrenheit, used by NFDRS and by several of the older single-equation indices.

func (Fahrenheit) Celsius

func (f Fahrenheit) Celsius() Celsius

Celsius converts f to degrees Celsius.

type Feet

type Feet float64

Feet is a length. NFDRS specifies its reference anemometer height in feet.

func (Feet) Meters

func (f Feet) Meters() Meters

Meters converts f to metres.

type Hectopascals

type Hectopascals float64

Hectopascals is a pressure, the conventional unit for both station pressure and for the Hot-Dry-Windy index's vapour pressure deficit term.

func (Hectopascals) Kilopascals

func (h Hectopascals) Kilopascals() Kilopascals

Kilopascals converts h to kilopascals.

type Inches

type Inches float64

Inches is a depth of precipitation, as required by NFDRS and the Keetch-Byram Drought Index.

func (Inches) Millimeters

func (i Inches) Millimeters() Millimeters

Millimeters converts i to millimetres.

type KilometersPerHour

type KilometersPerHour float64

KilometersPerHour is a wind speed, as required by the Canadian FWI system's Initial Spread Index.

func (KilometersPerHour) MetersPerSecond

func (v KilometersPerHour) MetersPerSecond() MetersPerSecond

MetersPerSecond converts v to metres per second.

type Kilopascals

type Kilopascals float64

Kilopascals is a pressure, used for vapour pressure and vapour pressure deficit.

func SaturationVaporPressure

func SaturationVaporPressure(t Celsius) Kilopascals

SaturationVaporPressure returns the saturation vapour pressure at temperature t, using the Magnus-Tetens approximation. The coefficients (0.6108, 17.27, 237.3) are the form given by Allen et al. 1998, FAO Irrigation and Drainage Paper 56, Equation 11.

func VaporPressureDeficit

func VaporPressureDeficit(t Celsius, rh Percent) Kilopascals

VaporPressureDeficit returns the difference between the saturation vapour pressure at t and the actual vapour pressure implied by rh.

VPD is a stronger predictor of fire activity in the Pacific Northwest than relative humidity alone, and is the moisture term of the Hot-Dry-Windy index.

func (Kilopascals) Hectopascals

func (k Kilopascals) Hectopascals() Hectopascals

Hectopascals converts k to hectopascals.

type Meters

type Meters float64

Meters is a length, used for sensor heights and station elevation.

const (
	// HeightFWI is the 10 m open exposure assumed by the Canadian FWI system.
	HeightFWI Meters = 10.0

	// HeightNFDRS is the 20 ft exposure assumed by NFDRS and by the RAWS
	// siting standard.
	HeightNFDRS Meters = 6.096
)

Reference anemometer heights assumed by the fire danger models.

func (Meters) Feet

func (m Meters) Feet() Feet

Feet converts m to feet.

type MetersPerSecond

type MetersPerSecond float64

MetersPerSecond is a wind speed. This is the canonical wind unit for observations.

func AdjustWindHeight

func AdjustWindHeight(v MetersPerSecond, from, to Meters, z0 Roughness) MetersPerSecond

AdjustWindHeight converts a wind speed measured at height from to the equivalent speed at height to, using the logarithmic wind profile.

This corrects for measurement height only. It does not correct for shelter: a sensor on a house in a treed neighbourhood is not measuring a slowed version of the open-country wind, it is measuring a different flow. Treat the result as a defensible approximation for trend purposes rather than as equivalent to a properly sited RAWS observation.

Example

ExampleAdjustWindHeight shows the wind-height correction. It raises a wind speed from a low sensor to the 10 m reference the Canadian system assumes.

package main

import (
	"fmt"

	firewx "alpineworks.io/firewx"
)

func main() {
	// A 3 m sensor over suburban roughness reads 4 m/s. Correct it up to 10 m.
	corrected := firewx.AdjustWindHeight(4, 3, firewx.HeightFWI, firewx.RoughnessSuburban)
	fmt.Printf("%.1f m/s\n", corrected)
}
Output:
6.7 m/s

func (MetersPerSecond) KilometersPerHour

func (v MetersPerSecond) KilometersPerHour() KilometersPerHour

KilometersPerHour converts v to kilometres per hour.

func (MetersPerSecond) MilesPerHour

func (v MetersPerSecond) MilesPerHour() MilesPerHour

MilesPerHour converts v to miles per hour.

type MilesPerHour

type MilesPerHour float64

MilesPerHour is a wind speed, as required by NFDRS and the Fosberg index.

func (MilesPerHour) MetersPerSecond

func (v MilesPerHour) MetersPerSecond() MetersPerSecond

MetersPerSecond converts v to metres per second.

type Millimeters

type Millimeters float64

Millimeters is a depth of precipitation, or a length. This is the canonical precipitation unit for observations.

func (Millimeters) Inches

func (m Millimeters) Inches() Inches

Inches converts m to inches.

type Obs

type Obs struct {
	Time time.Time `json:"time"`

	Temperature      Opt[Celsius]             `json:"temperature"`
	RelativeHumidity Opt[Percent]             `json:"relative_humidity"`
	WindSpeed        Opt[MetersPerSecond]     `json:"wind_speed"`
	WindDirection    Opt[Degrees]             `json:"wind_direction"`
	WindGust         Opt[MetersPerSecond]     `json:"wind_gust"`
	SolarRadiation   Opt[WattsPerSquareMeter] `json:"solar_radiation"`
	Pressure         Opt[Hectopascals]        `json:"pressure"`

	// Precipitation accumulated since the previous observation, not since
	// midnight and not a rate. Accumulation windows are a common source of
	// silent error when merging sources; normalise on ingest.
	Precipitation Opt[Millimeters] `json:"precipitation"`

	// PrecipDuration is the time during which precipitation actually fell
	// within the accumulation window. NFDRS requires this, and most stations
	// can only estimate it. A tipping bucket logged at a short archive
	// interval can derive it directly, which is one of the few respects in
	// which a well-configured personal station beats a RAWS.
	PrecipDuration Opt[time.Duration] `json:"precip_duration"`

	// SnowCovered suppresses fire danger output when set. NFDRS treats a
	// snow-covered station as having no fire danger regardless of the other
	// measurements.
	SnowCovered Opt[bool] `json:"snow_covered"`
}

Obs is a single weather observation.

Time is always UTC. Every measurement is optional, because every sensor can fail independently of the others; a station that has lost its pyranometer can still drive the Canadian FWI system, and one that has lost its rain gauge cannot drive either system correctly but should fail loudly rather than silently assume zero.

func (Obs) DewPoint

func (o Obs) DewPoint() Opt[Celsius]

DewPoint returns the dew point derived from temperature and relative humidity. It returns an empty Opt if either input is absent, or if the dew point is undefined.

The package DewPoint function returns NaN at a non-positive humidity. This method must not put that NaN into a present Opt, because NaN then propagates silently through the models. Therefore an undefined dew point becomes an absent value here.

func (Obs) VaporPressureDeficit

func (o Obs) VaporPressureDeficit() Opt[Kilopascals]

VaporPressureDeficit returns the VPD derived from temperature and relative humidity, or an empty Opt if either input is missing.

func (Obs) WindAt

func (o Obs) WindAt(s Station, h Meters) Opt[MetersPerSecond]

WindAt returns the observation's wind speed corrected from the station's anemometer height to the given reference height, or an empty Opt if the observation has no wind measurement.

type Opt

type Opt[T any] struct {
	// contains filtered or unexported fields
}

Opt holds a value that may be absent.

Missing data is the normal case, not an error case: RAWS drop hours routinely, consumer weather stations drop them more often, and a sensor can fail independently of the rest of the station. The alternative of signalling absence with NaN is a trap here, because NaN propagates silently through the finite-difference loop in the Nelson dead fuel moisture model and produces output that looks plausible but is not.

Opt marshals to JSON as either the bare value or null, so persisted observations and model state round-trip through a jsonb column without a wrapper object.

Example

ExampleOpt shows the optional value type. A present value holds data; an absent value holds nothing. This replaces the use of NaN for missing data.

package main

import (
	"fmt"

	firewx "alpineworks.io/firewx"
)

func main() {
	present := firewx.Some(firewx.Celsius(21.5))
	absent := firewx.None[firewx.Celsius]()

	if v, ok := present.Get(); ok {
		fmt.Printf("present: %.1f\n", v)
	}
	fmt.Printf("absent is valid: %t\n", absent.Valid())
}
Output:
present: 21.5
absent is valid: false

func MapOpt

func MapOpt[T, U any](o Opt[T], fn func(T) U) Opt[U]

MapOpt applies fn to the value of o if present. It is a free function rather than a method because Go does not permit additional type parameters on methods.

func None

func None[T any]() Opt[T]

None returns an empty Opt.

func Some

func Some[T any](v T) Opt[T]

Some returns an Opt holding v.

func (Opt[T]) Get

func (o Opt[T]) Get() (T, bool)

Get returns the value and whether it is present.

func (Opt[T]) MarshalJSON

func (o Opt[T]) MarshalJSON() ([]byte, error)

MarshalJSON encodes a present value as itself and an absent value as null.

func (Opt[T]) Must

func (o Opt[T]) Must() T

Must returns the value, panicking if it is absent. Intended for tests and for call sites that have already checked Valid.

func (Opt[T]) Or

func (o Opt[T]) Or(def T) T

Or returns the value if present, otherwise def.

func (*Opt[T]) UnmarshalJSON

func (o *Opt[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes null as an absent value and any other JSON as a present value.

func (Opt[T]) Valid

func (o Opt[T]) Valid() bool

Valid reports whether a value is present.

type Percent

type Percent float64

Percent is a relative humidity or a fuel moisture content, expressed as a percentage. Fuel moisture content routinely exceeds 100% and may reach several hundred percent for live fuels, so this type is deliberately not range-limited.

func RelativeHumidity

func RelativeHumidity(t, dew Celsius) Percent

RelativeHumidity returns the relative humidity implied by a temperature and dew point. Some data sources report dew point rather than RH.

type Roughness

type Roughness float64

Roughness is an aerodynamic roughness length in metres, characterising how much the surrounding terrain and vegetation slow the wind near the ground.

const (
	RoughnessWater      Roughness = 0.0002
	RoughnessMownGrass  Roughness = 0.01
	RoughnessOpenGrass  Roughness = 0.03 // the reference exposure both systems assume
	RoughnessFarmland   Roughness = 0.10
	RoughnessScattered  Roughness = 0.25
	RoughnessSuburban   Roughness = 0.50
	RoughnessForest     Roughness = 1.00
	RoughnessCityCentre Roughness = 2.00
)

Representative roughness lengths. These are order-of-magnitude guides, not measurements; if the corrected wind matters, calibrate against a nearby RAWS over a period of similar synoptic conditions rather than trusting a constant.

type Station

type Station struct {
	ID   string `json:"id"`
	Name string `json:"name,omitempty"`

	Latitude  float64 `json:"latitude"`
	Longitude float64 `json:"longitude"`
	Elevation Meters  `json:"elevation"`

	// LSTOffset is the fixed offset from UTC that defines local standard time
	// at this station.
	//
	// This is deliberately a fixed offset and not a *time.Location. Both the
	// Canadian FWI system and NFDRS define their observation windows in local
	// standard time, which does not shift with daylight saving. Using a
	// zoneinfo location here introduces a one hour discontinuity in the
	// carryover codes twice a year that is nearly invisible in the output and
	// extremely annoying to find.
	LSTOffset time.Duration `json:"lst_offset"`

	// AnemometerHeight is the height of the wind sensor above ground.
	//
	// The Canadian FWI system assumes 10 m open exposure; NFDRS assumes 20 ft
	// (6.096 m). A backyard installation is usually lower than both and more
	// sheltered than either, and the Initial Spread Index is sensitive enough
	// to wind that ignoring this materially changes the output.
	AnemometerHeight Meters `json:"anemometer_height"`

	// Roughness is the aerodynamic roughness length of the surrounding
	// terrain, used to correct wind speed between heights.
	Roughness Roughness `json:"roughness"`
}

Station describes the site an observation came from, including the terms needed to correct a non-standard sensor exposure toward the reference exposure each fire danger model assumes.

func (Station) LocalStandardTime

func (s Station) LocalStandardTime(t time.Time) time.Time

LocalStandardTime converts t to the station's local standard time. The returned time carries a fixed zone and never observes daylight saving.

type WattsPerSquareMeter

type WattsPerSquareMeter float64

WattsPerSquareMeter is an irradiance. NFDRS2016's Nelson dead fuel moisture model requires measured solar radiation; without it the dominant term in the fuel particle energy balance has to be estimated from cloud cover.

Directories

Path Synopsis
fetch module
nfdrs module
Package simple gives the single-equation fire danger indices.
Package simple gives the single-equation fire danger indices.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL