You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Extend DUET surface-fuel calibration so its target values can be derived from a
user-provided grid instead of only from scalars typed into the request. The primary
motivation is SB40 calibration as described in Tutland (2026), the duet-tools
package paper — but implemented so that we never fetch data from an external source at
DUET time. The user creates a standard grid resource (e.g. an FBFM40 grid, which itself
reads our GCS-hosted LANDFIRE rasters) and references it by id, mirroring how a DUET grid
already references its 3D tree grid via source_grid_id.
This is a design/investigation issue, not a ready-to-implement spec. Several core
questions are unresolved (see Open questions — the calibration-method semantics are the
biggest). Follow-on to #449 (which added DUET as a surface-fuel source).
How calibration works today
DUET's raw magnitudes are not physical, so calibration rescales its spatial pattern to
hit a target magnitude. Today the target is a user-typed scalar, per fuel parameter
(fuel_load / fuel_depth / fuel_moisture) × fuel type (grass / litter / …), using
one of three methods:
maxmin — rescale to a target max and min
meansd — rescale to a target mean and standard deviation
constant — assign a single value everywhere
treevox's _build_targets (services/treevox/treevox/handlers/duet.py) maps these
straight onto duet-tools' assign_targets(method=…). The request schema
(services/api/api/resources/grids/duet/schema.py) has no source field — the target
always comes from the request body. (The integration-test fixtures already carry a "source": "values" key, which the current schema would reject via extra="forbid" and
treevox ignores — a vestige of the discriminator this issue proposes to build.)
Proposed direction (not final)
Add a source discriminator to each calibration target:
sb40 — reference an FBFM40 code grid; treevox maps codes → grass/litter loadings
and derives the target over the domain
grid — reference any grid carrying a continuous loading band; treevox reads that
band directly (the natural generalization to RAP / RPMS / field data — grass-only)
// SB40 from a user-created FBFM40 grid — fills BOTH grass and litter"calibration": {
"fuel_load": {
"grass": { "source": "sb40", "grid_id": "<fbfm40-grid-id>", "method": "maxmin" },
"litter": { "source": "sb40", "grid_id": "<fbfm40-grid-id>", "method": "maxmin" }
}
}
// Generic band reference — the RAP/RPMS path (grass only)"grass": { "source": "grid", "grid_id": "<any-grid>", "band": "fuel_load.grass", "method": "maxmin" }
No external I/O: everything is a grid the user already created. A domain-statistic target
only needs the set of fuel models present, so the calibration grid need not be aligned to
the tree grid — treevox reads it, maps to loadings, computes the statistic.
What's already in place (so this needs no LFPS download)
We host the LANDFIRE FBFM40 rasters on GCS and have a first-class grids/fbfm40
resource plus an FBFM40 → fuel-parameter lookup operation.
duet-tools' SB40 mapping — _get_sb40_fuel_params / _get_sb40_arrays and the bundled data/sb40_parameters.csv — ship in our base install (pure pandas/numpy package
data). The only reason duet_tools.landfire fails to import is the missing landfire
PyPI client, which is used solely for the LFPS download (query_landfire). So the
code→loading math is available to us with zero network calls.
The grass/litter split lives in duet-tools' duet_dict: GR/GS/SH → grass, TL → litter, NB/TU/SB → neutral. The calibrated quantity is dry_load (1-hr dead + cured
live-herb), and × 0.22417 converts tons/acre → kg/m² (matching our fuel_load
bands; duet-tools' kg/m^3 comment is a mislabel).
Open questions (to resolve before implementing)
Do max/min and mean/sd even make sense for a grid-derived target? ⟵ the crux.
These methods were designed for a user typing one scalar. But an SB40 grid is a full spatial field. assign_targets_from_sb40 collapses it to a domain-wide max/min (or
mean/sd) — which, in a small stand covering only one or two fuel models, is derived from
as few as two loading values — and then stretches DUET's own, unrelated spatial
pattern to that statistic, discarding the SB40 spatial information entirely. Options to
weigh: (a) keep the statistical rescale but pick a sensible default method per source;
(b) introduce a spatial / per-cell (or per-region) match so DUET's magnitudes follow
the grid where it has real spatial structure — this is net-new, duet-tools does not offer
it today; (c) something else. This is the main thing to figure out.
Reproduce duet-tools' SB40 numbers, or define our own? duet-tools' dry_load
includes an ad-hoc live-herb curing factor alpha that its own source flags as "kind of a B.S. approach raised by Rod on a phone call with Anthony on 02/28/2023 … a
temporary fix for the BP3D team." Reproducing Tutland exactly inherits that hack and the
shrub-as-grass lumping; computing our own from our canonical sb40_fbfm40.csv is cleaner
but diverges from the paper.
FBFM40 code grid vs. pre-computed loading grid.sb40 references raw codes and puts
the grass/litter split in treevox; grid references a ready loading band and keeps
treevox dumb. There is currently no primitive that turns an FBFM40 grid into
DUET-vocabulary fuel_load.grass / fuel_load.litter bands (our lookup emits size-class bands), so the pure-grid path can't serve SB40 without new upstream work.
Which fuel types can each source calibrate, and how do we validate it? SB40 →
grass + litter; RAP / RPMS → grass only (they say nothing about litter). Reject a
grass-only source targeting litter?
Grid compatibility. For a domain statistic, overlap with the domain is enough. If we
go spatial (Q1), CRS/resolution alignment between the calibration grid and the tree grid
becomes a real requirement.
Reproducibility. Record the calibration grid's checksum (à la source_grid_checksum) so the DUET grid can be reproduced and staleness detected?
Units for the grid path. RAP / RPMS report lbs/acre → kg/m². Where does that
conversion live — grid construction, or calibration read?
Out of scope
Ingesting RAP / RPMS / GrassCast into grid resources (that's grid-construction work, not
DUET calibration). This issue only defines the calibration hook that would consume such
a grid.
GrassCast as a calibration source — it's a seasonal forecast, not an observed
current-fuels layer; defer unless scenario forecasting becomes a goal.
True per-cell spatial calibration is called out in Q1 but is not committed here.
Contract / downstream impact
Adds a source field + new enum values to the DUET calibration request schema →
OpenAPI, fastfuels-sdk-python, and the web API client all ripple (pre-PR checklist Add PIM TreeMap data product #1–4).
Introduces a grid-to-grid dependency (a DUET grid referencing a calibration grid) —
decide how it's recorded for reproducibility (Q6).
References
Tutland (2026), duet-tools: A python package for interacting with the DUET program.
(Zotero: 776YWPQJ.) Describes the SB40 calibration this generalizes.
Summary
Extend DUET surface-fuel calibration so its target values can be derived from a
user-provided grid instead of only from scalars typed into the request. The primary
motivation is SB40 calibration as described in Tutland (2026), the
duet-toolspackage paper — but implemented so that we never fetch data from an external source at
DUET time. The user creates a standard grid resource (e.g. an FBFM40 grid, which itself
reads our GCS-hosted LANDFIRE rasters) and references it by id, mirroring how a DUET grid
already references its 3D tree grid via
source_grid_id.This is a design/investigation issue, not a ready-to-implement spec. Several core
questions are unresolved (see Open questions — the calibration-method semantics are the
biggest). Follow-on to #449 (which added DUET as a surface-fuel source).
How calibration works today
DUET's raw magnitudes are not physical, so calibration rescales its spatial pattern to
hit a target magnitude. Today the target is a user-typed scalar, per fuel parameter
(
fuel_load/fuel_depth/fuel_moisture) × fuel type (grass/litter/ …), usingone of three methods:
maxmin— rescale to a target max and minmeansd— rescale to a target mean and standard deviationconstant— assign a single value everywheretreevox's_build_targets(services/treevox/treevox/handlers/duet.py) maps thesestraight onto duet-tools'
assign_targets(method=…). The request schema(
services/api/api/resources/grids/duet/schema.py) has nosourcefield — the targetalways comes from the request body. (The integration-test fixtures already carry a
"source": "values"key, which the current schema would reject viaextra="forbid"andtreevox ignores — a vestige of the discriminator this issue proposes to build.)
Proposed direction (not final)
Add a
sourcediscriminator to each calibration target:values— user-typed scalar (today's behavior; default)sb40— reference an FBFM40 code grid; treevox maps codes → grass/litter loadingsand derives the target over the domain
grid— reference any grid carrying a continuous loading band; treevox reads thatband directly (the natural generalization to RAP / RPMS / field data — grass-only)
No external I/O: everything is a grid the user already created. A domain-statistic target
only needs the set of fuel models present, so the calibration grid need not be aligned to
the tree grid — treevox reads it, maps to loadings, computes the statistic.
What's already in place (so this needs no LFPS download)
grids/fbfm40resource plus an FBFM40 → fuel-parameter
lookupoperation._get_sb40_fuel_params/_get_sb40_arraysand the bundleddata/sb40_parameters.csv— ship in our base install (pure pandas/numpy packagedata). The only reason
duet_tools.landfirefails to import is the missinglandfirePyPI client, which is used solely for the LFPS download (
query_landfire). So thecode→loading math is available to us with zero network calls.
duet_dict:GR/GS/SH → grass,TL → litter,NB/TU/SB → neutral. The calibrated quantity isdry_load(1-hr dead + curedlive-herb), and
× 0.22417converts tons/acre → kg/m² (matching ourfuel_loadbands; duet-tools'
kg/m^3comment is a mislabel).Open questions (to resolve before implementing)
Do max/min and mean/sd even make sense for a grid-derived target? ⟵ the crux.
These methods were designed for a user typing one scalar. But an SB40 grid is a full
spatial field.
assign_targets_from_sb40collapses it to a domain-wide max/min (ormean/sd) — which, in a small stand covering only one or two fuel models, is derived from
as few as two loading values — and then stretches DUET's own, unrelated spatial
pattern to that statistic, discarding the SB40 spatial information entirely. Options to
weigh: (a) keep the statistical rescale but pick a sensible default method per source;
(b) introduce a spatial / per-cell (or per-region) match so DUET's magnitudes follow
the grid where it has real spatial structure — this is net-new, duet-tools does not offer
it today; (c) something else. This is the main thing to figure out.
Reproduce duet-tools' SB40 numbers, or define our own? duet-tools'
dry_loadincludes an ad-hoc live-herb curing factor
alphathat its own source flags as"kind of a B.S. approach raised by Rod on a phone call with Anthony on 02/28/2023 … a
temporary fix for the BP3D team." Reproducing Tutland exactly inherits that hack and the
shrub-as-grass lumping; computing our own from our canonical
sb40_fbfm40.csvis cleanerbut diverges from the paper.
FBFM40 code grid vs. pre-computed loading grid.
sb40references raw codes and putsthe grass/litter split in treevox;
gridreferences a ready loading band and keepstreevox dumb. There is currently no primitive that turns an FBFM40 grid into
DUET-vocabulary
fuel_load.grass/fuel_load.litterbands (ourlookupemitssize-class bands), so the pure-
gridpath can't serve SB40 without new upstream work.Which fuel types can each source calibrate, and how do we validate it? SB40 →
grass + litter; RAP / RPMS → grass only (they say nothing about litter). Reject a
grass-only source targeting litter?
Grid compatibility. For a domain statistic, overlap with the domain is enough. If we
go spatial (Q1), CRS/resolution alignment between the calibration grid and the tree grid
becomes a real requirement.
Reproducibility. Record the calibration grid's
checksum(à lasource_grid_checksum) so the DUET grid can be reproduced and staleness detected?Units for the
gridpath. RAP / RPMS report lbs/acre → kg/m². Where does thatconversion live — grid construction, or calibration read?
Out of scope
DUET calibration). This issue only defines the calibration hook that would consume such
a grid.
current-fuels layer; defer unless scenario forecasting becomes a goal.
Contract / downstream impact
sourcefield + new enum values to the DUET calibration request schema →OpenAPI,
fastfuels-sdk-python, and the web API client all ripple (pre-PR checklist Add PIM TreeMap data product #1–4).decide how it's recorded for reproducibility (Q6).
References
(Zotero:
776YWPQJ.) Describes the SB40 calibration this generalizes.landfire.py:assign_targets_from_sb40,query_landfire,_get_sb40_fuel_params,_get_sb40_arrays,data/sb40_parameters.csv.services/api/api/resources/grids/duet/schema.py(calibration models);services/treevox/treevox/handlers/duet.py(_build_targets,_calibrate);services/griddle/griddle/data/sb40_fbfm40.csv(our SB40 table).