Skip to content

Grid-derived DUET calibration targets (SB40 from a user grid; generalize to RAP/RPMS) #454

Description

@amarcozzi

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-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:

  • values — user-typed scalar (today's behavior; default)
  • 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.csvship 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)

  1. 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.

  2. 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.

  3. 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.

  4. 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?

  5. 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.

  6. Reproducibility. Record the calibration grid's checksum (à la
    source_grid_checksum) so the DUET grid can be reproduced and staleness detected?

  7. 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.
  • duet-tools landfire.py: assign_targets_from_sb40, query_landfire,
    _get_sb40_fuel_params, _get_sb40_arrays, data/sb40_parameters.csv.
  • This repo: 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).
  • Parent: Add DUET as a source of surface fuel load, depth, and moisture #449 (Add DUET as a source of surface fuel load, depth, and moisture).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions