From e8f0f83e1e071339fcaab889f54dc6b05125f31d Mon Sep 17 00:00:00 2001 From: LindsayWiard Date: Tue, 25 Aug 2026 11:01:35 -0600 Subject: [PATCH 1/5] Add integration tests for annual LFPS fetch and seasonal staleness check --- .../tests/integration/test_landfire_lfps.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/services/griddle/tests/integration/test_landfire_lfps.py b/services/griddle/tests/integration/test_landfire_lfps.py index 20a26ce1..3d887dd3 100644 --- a/services/griddle/tests/integration/test_landfire_lfps.py +++ b/services/griddle/tests/integration/test_landfire_lfps.py @@ -12,7 +12,7 @@ import pytest from griddle.handlers import landfire -from lib.landfire import list_products +from lib.landfire import LANDFIRE_VERSIONS, list_products from lib.testing import SHARED_TEST_DOMAINS_DIR @@ -72,3 +72,45 @@ def test_seasonal_fbfm40(roi): fbfm_valid = _assert_valid_data(ds, "fbfm") assert fbfm_valid.max() <= 204 # matches test_landfire.py's FBFM40 range check + + +def test_configured_seasonal_version_still_live(): + """Fails once LANDFIRE moves seasonal fuels past our configured + version -- signals LANDFIRE_VERSIONS["fbfm40"]["lfps_available"] + needs updating, rather than silently going stale.""" + version = LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0] + live = any( + p.acronym.upper() == "FBFM40" + and p.season is not None + and p.version == f"LF{version}" + for p in list_products() + ) + assert live, ( + f"No live seasonal FBFM40 product for version {version} -- LANDFIRE may " + 'have moved on; update LANDFIRE_VERSIONS["fbfm40"]["lfps_available"].' + ) + + +@pytest.mark.parametrize( + ("product", "fetch_fn", "band"), + [ + ("fbfm13", landfire.fetch_fbfm13, "fbfm13"), + ("fbfm40", landfire.fetch_fbfm40, "fbfm"), + ("fccs", landfire.fetch_fccs, "fccs"), + ], +) +def test_annual_lfps_available_version(roi, product, fetch_fn, band): + """Submit a real annual (non-seasonal) LFPS job for each product's + configured lfps_available version -- confirms LANDFIRE Product Service + is actually serving what our config says it should.""" + lfps_versions = LANDFIRE_VERSIONS[product].get("lfps_available", []) + if not lfps_versions: + pytest.skip(f"No lfps_available version configured for {product}.") + + ds = fetch_fn(roi, version=lfps_versions[0], progress=lambda *a, **k: None) + + assert band in ds.data_vars + assert ds[band].dims == ("y", "x") + assert ds[band].rio.nodata is not None + assert ds.rio.height > 0 + assert ds.rio.width > 0 From 8913df89929d5d56bb4c59cb8bee3547aeea7e51 Mon Sep 17 00:00:00 2001 From: LindsayWiard Date: Thu, 27 Aug 2026 12:07:08 -0600 Subject: [PATCH 2/5] Add latest-year version to LANDFIRE fuel model API schemas --- .../api/api/resources/grids/fbfm13/schema.py | 23 +++++++++++++++---- .../api/api/resources/grids/fbfm40/schema.py | 23 ++++++++----------- .../api/api/resources/grids/fccs/schema.py | 23 +++++++++++++++---- .../resources/grids/fbfm13/test_schema.py | 11 +++++++++ .../resources/grids/fbfm40/test_schema.py | 17 +++++++++----- .../tests/resources/grids/fccs/test_schema.py | 11 +++++++++ 6 files changed, 80 insertions(+), 28 deletions(-) diff --git a/services/api/api/resources/grids/fbfm13/schema.py b/services/api/api/resources/grids/fbfm13/schema.py index 497f8b0f..803f8293 100644 --- a/services/api/api/resources/grids/fbfm13/schema.py +++ b/services/api/api/resources/grids/fbfm13/schema.py @@ -12,7 +12,7 @@ from enum import StrEnum from typing import Literal -from pydantic import field_validator +from pydantic import Field, field_validator from api.resources.grids.providers.landfire import ( LandfireSource, @@ -22,10 +22,18 @@ from api.resources.grids.schema import Band, BandType, CreateSourceGridRequestBase from lib.landfire import LANDFIRE_VERSIONS -# Build the version enum class from LANDFIRE_VERSIONS +# Build the version enum class from LANDFIRE_VERSIONS, combining both +# "available" (staged annual) and "lfps_available" (current-year, fetched +# on demand from LANDFIRE Product Service) versions. +_FBFM13_ALL_VERSIONS = list( + dict.fromkeys( + LANDFIRE_VERSIONS["fbfm13"]["available"] + + LANDFIRE_VERSIONS["fbfm13"]["lfps_available"] + ) +) LandfireFbfm13Version = StrEnum( "LandfireFbfm13Version", - {f"v{version}": version for version in LANDFIRE_VERSIONS["fbfm13"]["available"]}, + {f"v{version}": version for version in _FBFM13_ALL_VERSIONS}, ) @@ -50,8 +58,13 @@ class CreateLandfireFbfm13Request(CreateSourceGridRequestBase): To convert codes to fuel parameters, use /grids/lookup/fbfm13. """ - version: LandfireFbfm13Version = LandfireFbfm13Version( - LANDFIRE_VERSIONS["fbfm13"]["default"] + version: LandfireFbfm13Version = Field( + default=LandfireFbfm13Version(LANDFIRE_VERSIONS["fbfm13"]["default"]), + description=( + "LANDFIRE version. Most years are served from staged LANDFIRE data; " + f"{', '.join(LANDFIRE_VERSIONS['fbfm13']['lfps_available'])} " + "is instead fetched on demand from LANDFIRE Product Service." + ), ) remove_non_burnable: list[NonBurnableFuelModel] | None = None diff --git a/services/api/api/resources/grids/fbfm40/schema.py b/services/api/api/resources/grids/fbfm40/schema.py index f45148ee..3d6bf7e8 100644 --- a/services/api/api/resources/grids/fbfm40/schema.py +++ b/services/api/api/resources/grids/fbfm40/schema.py @@ -89,9 +89,13 @@ class CreateLandfireFbfm40Request(CreateSourceGridRequestBase): version: LandfireFbfm40Version = Field( default=LandfireFbfm40Version(LANDFIRE_VERSIONS["fbfm40"]["default"]), description=( - "LANDFIRE landscape vintage year (e.g. '2024'). With `season` set, " - "this is the base vintage for LANDFIRE Seasonal Fuels; the calendar " - "year the data represents is reported back as `year`." + "LANDFIRE landscape vintage year (e.g. '2024'). Most years are " + "served from staged LANDFIRE data; version " + f"{', '.join(LANDFIRE_VERSIONS['fbfm40']['lfps_available'])} " + "is instead fetched on demand from LANDFIRE Product Service. " + "With `season` set, this is the base vintage for LANDFIRE " + "Seasonal Fuels; the calendar year the data represents is " + "reported back as `year`." ), ) remove_non_burnable: list[NonBurnableFuelModel] | None = None @@ -121,18 +125,11 @@ def check_version_matches_season(self): """ versions = LANDFIRE_VERSIONS["fbfm40"] - if self.season is None and self.version not in versions["available"]: - raise ValueError( - f"version {self.version} is only available for seasonal " - f"(season=...) requests. Available annual versions: " - f"{', '.join(versions['available'])}." - ) - if self.season is not None and self.version not in versions["lfps_available"]: raise ValueError( - f"version {self.version} is not available for LANDFIRE " - f"Seasonal Fuels. Available seasonal versions: " - f"{', '.join(versions['lfps_available'])}." + f"LANDFIRE Seasonal Fuels are only available for version " + f"{', '.join(versions['lfps_available'])}, not for version " + f"{self.version}." ) return self diff --git a/services/api/api/resources/grids/fccs/schema.py b/services/api/api/resources/grids/fccs/schema.py index 977e1ca8..2d794258 100644 --- a/services/api/api/resources/grids/fccs/schema.py +++ b/services/api/api/resources/grids/fccs/schema.py @@ -11,14 +11,24 @@ from enum import StrEnum from typing import Literal +from pydantic import Field + from api.resources.grids.providers.landfire import LandfireSource from api.resources.grids.schema import Band, BandType, CreateSourceGridRequestBase from lib.landfire import LANDFIRE_VERSIONS -# Build the version enum class from LANDFIRE_VERSIONS +# Build the version enum class from LANDFIRE_VERSIONS, combining both +# "available" (staged annual) and "lfps_available" (current-year, fetched +# on demand from LANDFIRE Product Service) versions. +_FCCS_ALL_VERSIONS = list( + dict.fromkeys( + LANDFIRE_VERSIONS["fccs"]["available"] + + LANDFIRE_VERSIONS["fccs"]["lfps_available"] + ) +) LandfireFccsVersion = StrEnum( "LandfireFccsVersion", - {f"v{version}": version for version in LANDFIRE_VERSIONS["fccs"]["available"]}, + {f"v{version}": version for version in _FCCS_ALL_VERSIONS}, ) @@ -41,8 +51,13 @@ class CreateLandfireFccsRequest(CreateSourceGridRequestBase): To convert IDs to fuel parameters, use /grids/lookup/fccs. """ - version: LandfireFccsVersion = LandfireFccsVersion( - LANDFIRE_VERSIONS["fccs"]["default"] + version: LandfireFccsVersion = Field( + default=LandfireFccsVersion(LANDFIRE_VERSIONS["fccs"]["default"]), + description=( + "LANDFIRE version. Most years are served from staged LANDFIRE " + f"data; {', '.join(LANDFIRE_VERSIONS['fccs']['lfps_available'])} " + "is instead fetched on demand from LANDFIRE Product Service." + ), ) remove_bare_ground: bool = False diff --git a/services/api/tests/resources/grids/fbfm13/test_schema.py b/services/api/tests/resources/grids/fbfm13/test_schema.py index feeb8277..c29bc7b1 100644 --- a/services/api/tests/resources/grids/fbfm13/test_schema.py +++ b/services/api/tests/resources/grids/fbfm13/test_schema.py @@ -15,6 +15,8 @@ from api.resources.grids.schema import BandType from pydantic import ValidationError +from lib.landfire import LANDFIRE_VERSIONS + class TestLandfireFbfm13Source: """Tests for LandfireFbfm13Source model.""" @@ -85,6 +87,15 @@ def test_invalid_version_rejected(self): with pytest.raises(ValidationError): CreateLandfireFbfm13Request(version="2021") + def test_lfps_available_version_accepted(self): + """The current lfps_available version is accepted -- + the annual "latest release" case, routed through LFPS.""" + + version = LANDFIRE_VERSIONS["fbfm13"]["lfps_available"][0] + request = CreateLandfireFbfm13Request(version=version) + + assert request.version == version + def test_full_request_with_all_fields(self): """Full request with all optional fields.""" request = CreateLandfireFbfm13Request( diff --git a/services/api/tests/resources/grids/fbfm40/test_schema.py b/services/api/tests/resources/grids/fbfm40/test_schema.py index 049fd832..44632946 100644 --- a/services/api/tests/resources/grids/fbfm40/test_schema.py +++ b/services/api/tests/resources/grids/fbfm40/test_schema.py @@ -19,7 +19,7 @@ from api.resources.grids.schema import BandType from pydantic import ValidationError -from lib.landfire import NB_CODE_MAP, SEASON_CODES +from lib.landfire import LANDFIRE_VERSIONS, NB_CODE_MAP, SEASON_CODES class TestLandfireSource: @@ -146,6 +146,16 @@ def test_invalid_version_rejected(self): with pytest.raises(ValidationError): CreateLandfireFbfm40Request(version="2021") + def test_lfps_available_version_accepted(self): + """The current lfps_available version is accepted with no season -- + the annual "latest release" case, routed through LFPS.""" + + version = LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0] + request = CreateLandfireFbfm40Request(version=version) + + assert request.version == version + assert request.season is None + def test_full_request_with_all_fields(self): """Full request with all optional fields.""" request = CreateLandfireFbfm40Request( @@ -208,11 +218,6 @@ def test_request_rejects_invalid_season(self): with pytest.raises(ValidationError): CreateLandfireFbfm40Request(version="2025", season="XX") - def test_seasonal_version_without_season_rejected(self): - """An LFPS-only version with no season is rejected.""" - with pytest.raises(ValidationError): - CreateLandfireFbfm40Request(version="2025") - def test_annual_version_with_season_rejected(self): """A staged-annual-only version with season set is rejected.""" with pytest.raises(ValidationError): diff --git a/services/api/tests/resources/grids/fccs/test_schema.py b/services/api/tests/resources/grids/fccs/test_schema.py index 13f8f770..11083363 100644 --- a/services/api/tests/resources/grids/fccs/test_schema.py +++ b/services/api/tests/resources/grids/fccs/test_schema.py @@ -15,6 +15,8 @@ from api.resources.grids.schema import BandType from pydantic import ValidationError +from lib.landfire import LANDFIRE_VERSIONS + class TestLandfireFccsSource: """Tests for LandfireFccsSource model.""" @@ -85,6 +87,15 @@ def test_invalid_version_rejected(self): with pytest.raises(ValidationError): CreateLandfireFccsRequest(version="2021") + def test_lfps_available_version_accepted(self): + """The current lfps_available version is accepted -- + the annual "latest release" case, routed through LFPS.""" + + version = LANDFIRE_VERSIONS["fccs"]["lfps_available"][0] + request = CreateLandfireFccsRequest(version=version) + + assert request.version == version + def test_full_request_with_all_fields(self): """Full request with all optional fields.""" request = CreateLandfireFccsRequest( From 22e9d80b60b3d2d5a7392660df14213b60931d05 Mon Sep 17 00:00:00 2001 From: LindsayWiard Date: Thu, 27 Aug 2026 12:10:00 -0600 Subject: [PATCH 3/5] Require LFPS coverage check for the latest-year version in LANDFIRE fuel model API routers --- services/api/api/resources/grids/fbfm13/router.py | 11 +++++++++++ services/api/api/resources/grids/fbfm40/router.py | 14 ++++++++------ services/api/api/resources/grids/fccs/router.py | 11 +++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/services/api/api/resources/grids/fbfm13/router.py b/services/api/api/resources/grids/fbfm13/router.py index 9121ea67..91dd7f5d 100644 --- a/services/api/api/resources/grids/fbfm13/router.py +++ b/services/api/api/resources/grids/fbfm13/router.py @@ -4,6 +4,7 @@ Router for FBFM13 grid product endpoints. """ +import asyncio import uuid from datetime import datetime from typing import Annotated @@ -25,11 +26,13 @@ from api.resources.grids.utils import ( dump_modifications_for_firestore, validate_feature_modifications, + validate_lfps_coverage, validate_target_grid_alignment, ) from api.schema import JobStatus from api.tasks import create_http_task_async from lib.config import GRIDDLE_QUEUE, GRIDDLE_SERVICE, GRIDS_COLLECTION +from lib.landfire import LANDFIRE_VERSIONS router = APIRouter() @@ -82,6 +85,14 @@ async def create_landfire_fbfm13( await validate_target_grid_alignment(body.alignment, owner_id, domain_id) await validate_feature_modifications(body.modifications, owner_id, domain_id) + if body.version in LANDFIRE_VERSIONS["fbfm13"]["lfps_available"]: + await asyncio.to_thread( + validate_lfps_coverage, + "fbfm13", + body.version, + domain, + ) + grid_id = uuid.uuid4().hex request_time = datetime.now() source = LandfireFbfm13Source( diff --git a/services/api/api/resources/grids/fbfm40/router.py b/services/api/api/resources/grids/fbfm40/router.py index 15604b86..ba3d8351 100644 --- a/services/api/api/resources/grids/fbfm40/router.py +++ b/services/api/api/resources/grids/fbfm40/router.py @@ -32,7 +32,7 @@ from api.schema import JobStatus from api.tasks import create_http_task_async from lib.config import GRIDDLE_QUEUE, GRIDDLE_SERVICE, GRIDS_COLLECTION -from lib.landfire import resolve_seasonal_product +from lib.landfire import LANDFIRE_VERSIONS, resolve_seasonal_product router = APIRouter() @@ -73,11 +73,8 @@ async def create_landfire_fbfm40( - **description**: (optional) Description. - **tags**: (optional) Tags for organizing grids. - **version**: (optional) LANDFIRE version. Default: "2024". - Fetches data from a saved copy of the annual release, unless `season` is set. - **season**: (optional) LANDFIRE Seasonal Fuels release: "ES" (early - spring), "SP" (spring), "SU" (summer), or "FA" (fall). Setting - `season` fetches data from the LANDFIRE Product Service on demand - rather than a saved annual copy. + spring), "SP" (spring), "SU" (summer), or "FA" (fall). ## Response @@ -96,7 +93,11 @@ async def create_landfire_fbfm40( await validate_target_grid_alignment(body.alignment, owner_id, domain_id) await validate_feature_modifications(body.modifications, owner_id, domain_id) - if body.season is not None: + use_lfps = ( + body.season is not None + or body.version in LANDFIRE_VERSIONS["fbfm40"]["lfps_available"] + ) + if use_lfps: await asyncio.to_thread( validate_lfps_coverage, "fbfm40", @@ -104,6 +105,7 @@ async def create_landfire_fbfm40( domain, season=body.season, ) + if body.season is not None: # Read the represented year off the live LFPS catalog entry rather # than assuming it is `version + 1`. Coverage validation above already # confirmed the product is live, so the match is present (cached call). diff --git a/services/api/api/resources/grids/fccs/router.py b/services/api/api/resources/grids/fccs/router.py index 9bf9cdeb..b31fa681 100644 --- a/services/api/api/resources/grids/fccs/router.py +++ b/services/api/api/resources/grids/fccs/router.py @@ -4,6 +4,7 @@ Router for FCCS grid product endpoints. """ +import asyncio import uuid from datetime import datetime from typing import Annotated @@ -25,11 +26,13 @@ from api.resources.grids.utils import ( dump_modifications_for_firestore, validate_feature_modifications, + validate_lfps_coverage, validate_target_grid_alignment, ) from api.schema import JobStatus from api.tasks import create_http_task_async from lib.config import GRIDDLE_QUEUE, GRIDDLE_SERVICE, GRIDS_COLLECTION +from lib.landfire import LANDFIRE_VERSIONS router = APIRouter() @@ -86,6 +89,14 @@ async def create_landfire_fccs( await validate_target_grid_alignment(body.alignment, owner_id, domain_id) await validate_feature_modifications(body.modifications, owner_id, domain_id) + if body.version in LANDFIRE_VERSIONS["fccs"]["lfps_available"]: + await asyncio.to_thread( + validate_lfps_coverage, + "fccs", + body.version, + domain, + ) + grid_id = uuid.uuid4().hex request_time = datetime.now() source = LandfireFccsSource( From 2fb9aa507a5af9dee336a30854be28db9d26bcba Mon Sep 17 00:00:00 2001 From: LindsayWiard Date: Thu, 27 Aug 2026 12:10:55 -0600 Subject: [PATCH 4/5] Add latest-year examples and live-coverage tests to API LANDFIRE fuel models --- .../api/resources/grids/fbfm13/examples.py | 21 +++++++++- .../api/resources/grids/fbfm40/examples.py | 16 +++++++- .../api/api/resources/grids/fccs/examples.py | 21 +++++++++- .../resources/grids/fbfm13/test_router.py | 29 +++++++++++++- .../resources/grids/fbfm40/test_router.py | 39 ++++++++++++------- .../tests/resources/grids/fccs/test_router.py | 29 +++++++++++++- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/services/api/api/resources/grids/fbfm13/examples.py b/services/api/api/resources/grids/fbfm13/examples.py index 59281c99..212e7b66 100644 --- a/services/api/api/resources/grids/fbfm13/examples.py +++ b/services/api/api/resources/grids/fbfm13/examples.py @@ -8,6 +8,8 @@ domain_id comes from the URL path parameter, not the request body. """ +from lib.landfire import LANDFIRE_VERSIONS + EXAMPLE_FBFM13_MINIMAL = {} EXAMPLE_FBFM13_WITH_METADATA = { @@ -37,6 +39,11 @@ "alignment": {"target": "native"}, } +EXAMPLE_FBFM13_LATEST_RELEASE = { + "name": "FBFM13 latest release", + "version": LANDFIRE_VERSIONS["fbfm13"]["lfps_available"][0], +} + CREATE_LANDFIRE_FBFM13_OPENAPI_EXAMPLES = { "minimal": { "value": EXAMPLE_FBFM13_MINIMAL, @@ -94,9 +101,17 @@ "domain-anchored grids without further alignment." ), }, + "latest_release": { + "value": EXAMPLE_FBFM13_LATEST_RELEASE, + "summary": "Latest release", + "description": ( + "Fetches the latest FBFM13 release on demand from LANDFIRE " + "Product Service instead of a staged release." + ), + }, } -ALL_FBFM13_EXAMPLE_VALUES = [ +STAGED_FBFM13_EXAMPLE_VALUES = [ ("minimal", EXAMPLE_FBFM13_MINIMAL), ("with_metadata", EXAMPLE_FBFM13_WITH_METADATA), ("remove_non_burnable", EXAMPLE_FBFM13_REMOVE_NON_BURNABLE), @@ -104,3 +119,7 @@ ("domain_aligned_2m", EXAMPLE_FBFM13_DOMAIN_2M), ("native_anchor", EXAMPLE_FBFM13_NATIVE), ] + +LFPS_FBFM13_EXAMPLE_VALUES = [ + ("latest_release", EXAMPLE_FBFM13_LATEST_RELEASE), +] diff --git a/services/api/api/resources/grids/fbfm40/examples.py b/services/api/api/resources/grids/fbfm40/examples.py index dd612b81..5296563f 100644 --- a/services/api/api/resources/grids/fbfm40/examples.py +++ b/services/api/api/resources/grids/fbfm40/examples.py @@ -40,11 +40,16 @@ } EXAMPLE_FBFM40_SEASONAL = { - "name": "FBFM40 spring seasonal fuels", + "name": "FBFM40 early spring seasonal fuels", "version": LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0], "season": "ES", } +EXAMPLE_FBFM40_LATEST_RELEASE = { + "name": "FBFM40 latest release", + "version": LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0], +} + CREATE_LANDFIRE_FBFM40_OPENAPI_EXAMPLES = { "minimal": { "value": EXAMPLE_FBFM40_MINIMAL, @@ -112,6 +117,14 @@ "currently serves on-demand." ), }, + "latest_release": { + "value": EXAMPLE_FBFM40_LATEST_RELEASE, + "summary": "Latest release", + "description": ( + "Fetches the latest FBFM40 release on demand from LANDFIRE " + "Product Service instead of a staged release." + ), + }, } STAGED_FBFM40_EXAMPLE_VALUES = [ @@ -125,4 +138,5 @@ LFPS_FBFM40_EXAMPLE_VALUES = [ ("seasonal", EXAMPLE_FBFM40_SEASONAL), + ("latest_release", EXAMPLE_FBFM40_LATEST_RELEASE), ] diff --git a/services/api/api/resources/grids/fccs/examples.py b/services/api/api/resources/grids/fccs/examples.py index 42f306a5..9ff4e1ab 100644 --- a/services/api/api/resources/grids/fccs/examples.py +++ b/services/api/api/resources/grids/fccs/examples.py @@ -8,6 +8,8 @@ domain_id comes from the URL path parameter, not the request body. """ +from lib.landfire import LANDFIRE_VERSIONS + EXAMPLE_FCCS_MINIMAL = {} EXAMPLE_FCCS_WITH_METADATA = { @@ -37,6 +39,11 @@ "alignment": {"target": "native"}, } +EXAMPLE_FCCS_LATEST_RELEASE = { + "name": "FCCS latest release", + "version": LANDFIRE_VERSIONS["fccs"]["lfps_available"][0], +} + CREATE_LANDFIRE_FCCS_OPENAPI_EXAMPLES = { "minimal": { "value": EXAMPLE_FCCS_MINIMAL, @@ -93,9 +100,17 @@ "domain-anchored grids without further alignment." ), }, + "latest_release": { + "value": EXAMPLE_FCCS_LATEST_RELEASE, + "summary": "Latest release", + "description": ( + "Fetches the latest FCCS release on demand from LANDFIRE " + "Product Service instead of a staged release." + ), + }, } -ALL_FCCS_EXAMPLE_VALUES = [ +STAGED_FCCS_EXAMPLE_VALUES = [ ("minimal", EXAMPLE_FCCS_MINIMAL), ("with_metadata", EXAMPLE_FCCS_WITH_METADATA), ("remove_bare_ground", EXAMPLE_FCCS_REMOVE_BARE_GROUND), @@ -103,3 +118,7 @@ ("domain_aligned_2m", EXAMPLE_FCCS_DOMAIN_2M), ("native_anchor", EXAMPLE_FCCS_NATIVE), ] + +LFPS_FCCS_EXAMPLE_VALUES = [ + ("latest_release", EXAMPLE_FCCS_LATEST_RELEASE), +] diff --git a/services/api/tests/resources/grids/fbfm13/test_router.py b/services/api/tests/resources/grids/fbfm13/test_router.py index a95e4aa0..7ba49c1a 100644 --- a/services/api/tests/resources/grids/fbfm13/test_router.py +++ b/services/api/tests/resources/grids/fbfm13/test_router.py @@ -7,7 +7,8 @@ import pytest from api.resources.grids.fbfm13.examples import ( - ALL_FBFM13_EXAMPLE_VALUES, + LFPS_FBFM13_EXAMPLE_VALUES, + STAGED_FBFM13_EXAMPLE_VALUES, ) @@ -93,7 +94,7 @@ def test_response_excludes_owner_id(self, client, domain_for_testing): data = response.json() assert "owner_id" not in data - @pytest.mark.parametrize("example_name,example_value", ALL_FBFM13_EXAMPLE_VALUES) + @pytest.mark.parametrize("example_name,example_value", STAGED_FBFM13_EXAMPLE_VALUES) def test_documented_example_creates_grid( self, client, domain_for_testing, example_name, example_value ): @@ -147,3 +148,27 @@ def test_extent_buffer_cells_above_maximum_rejected( ) assert response.status_code == 422 + + +class TestLfpsCoverage: + """Real LFPS-coverage tests for the annual "latest release" example -- + routed through validate_lfps_coverage.""" + + def route(self, domain_id): + return f"/domains/{domain_id}/grids/fbfm13/landfire" + + @pytest.mark.parametrize("example_name,example_value", LFPS_FBFM13_EXAMPLE_VALUES) + def test_lfps_documented_example_creates_grid( + self, client, lfps_covered_domain, example_name, example_value + ): + """Confirms validate_lfps_coverage succeeds against real LFPS for a + domain with known coverage -- proving the coverage-check path works + end-to-end, not just under mocks.""" + + response = client.post( + self.route(lfps_covered_domain["id"]), json=example_value + ) + assert response.status_code == 201, ( + f"Example '{example_name}' failed with status {response.status_code}: " + f"{response.json()}" + ) diff --git a/services/api/tests/resources/grids/fbfm40/test_router.py b/services/api/tests/resources/grids/fbfm40/test_router.py index 14b20295..cfddda58 100644 --- a/services/api/tests/resources/grids/fbfm40/test_router.py +++ b/services/api/tests/resources/grids/fbfm40/test_router.py @@ -148,6 +148,30 @@ def test_extent_buffer_cells_above_maximum_rejected( assert response.status_code == 422 +class TestLfpsCoverage: + """Real LFPS-coverage tests for both the seasonal and annual LFPS-sourced + examples -- everything routed through validate_lfps_coverage.""" + + def route(self, domain_id): + return f"/domains/{domain_id}/grids/fbfm40/landfire" + + @pytest.mark.parametrize("example_name,example_value", LFPS_FBFM40_EXAMPLE_VALUES) + def test_lfps_documented_example_creates_grid( + self, client, lfps_covered_domain, example_name, example_value + ): + """Confirms validate_lfps_coverage succeeds against real LFPS for a + domain with known coverage -- proving the coverage-check path works + end-to-end, not just under mocks.""" + + response = client.post( + self.route(lfps_covered_domain["id"]), json=example_value + ) + assert response.status_code == 201, ( + f"Example '{example_name}' failed with status {response.status_code}: " + f"{response.json()}" + ) + + class TestSeasonalCoverage: """Router-level tests for the `season` field on grid creation.""" @@ -181,18 +205,3 @@ def test_season_omitted_persists_none_and_annual_year( source = response.json()["source"] assert source["season"] is None assert source["year"] == 2024 # default version - - @pytest.mark.parametrize("example_name,example_value", LFPS_FBFM40_EXAMPLE_VALUES) - def test_lfps_documented_example_creates_grid( - self, client, lfps_covered_domain, example_name, example_value - ): - """Each LFPS-sourced example should successfully create a grid - against a domain known to have LFPS coverage. A failure here after - LANDFIRE rotates its live season means the example needs updating.""" - response = client.post( - self.route(lfps_covered_domain["id"]), json=example_value - ) - assert response.status_code == 201, ( - f"Example '{example_name}' failed with status {response.status_code}: " - f"{response.json()}" - ) diff --git a/services/api/tests/resources/grids/fccs/test_router.py b/services/api/tests/resources/grids/fccs/test_router.py index 354caa51..d4c643bb 100644 --- a/services/api/tests/resources/grids/fccs/test_router.py +++ b/services/api/tests/resources/grids/fccs/test_router.py @@ -7,7 +7,8 @@ import pytest from api.resources.grids.fccs.examples import ( - ALL_FCCS_EXAMPLE_VALUES, + LFPS_FCCS_EXAMPLE_VALUES, + STAGED_FCCS_EXAMPLE_VALUES, ) @@ -91,7 +92,7 @@ def test_response_excludes_owner_id(self, client, domain_for_testing): data = response.json() assert "owner_id" not in data - @pytest.mark.parametrize("example_name,example_value", ALL_FCCS_EXAMPLE_VALUES) + @pytest.mark.parametrize("example_name,example_value", STAGED_FCCS_EXAMPLE_VALUES) def test_documented_example_creates_grid( self, client, domain_for_testing, example_name, example_value ): @@ -152,3 +153,27 @@ def test_alignment_defaults_to_domain_target(self, client, domain_for_testing): assert response.status_code == 201 assert response.json()["source"]["alignment"]["target"] == "domain" + + +class TestLfpsCoverage: + """Real LFPS-coverage tests for the annual "latest release" example -- + routed through validate_lfps_coverage.""" + + def route(self, domain_id): + return f"/domains/{domain_id}/grids/fccs/landfire" + + @pytest.mark.parametrize("example_name,example_value", LFPS_FCCS_EXAMPLE_VALUES) + def test_lfps_documented_example_creates_grid( + self, client, lfps_covered_domain, example_name, example_value + ): + """Confirms validate_lfps_coverage succeeds against real LFPS for a + domain with known coverage -- proving the coverage-check path works + end-to-end, not just under mocks.""" + + response = client.post( + self.route(lfps_covered_domain["id"]), json=example_value + ) + assert response.status_code == 201, ( + f"Example '{example_name}' failed with status {response.status_code}: " + f"{response.json()}" + ) From def3fb2dc5dfac4195d81272726430aa9ee8da08 Mon Sep 17 00:00:00 2001 From: LindsayWiard Date: Fri, 28 Aug 2026 12:43:16 -0600 Subject: [PATCH 5/5] Improve wording of examples of LANDFIRE fuel models sourced from LFPS --- services/api/api/resources/grids/fbfm13/examples.py | 4 +++- services/api/api/resources/grids/fbfm40/examples.py | 4 +++- services/api/api/resources/grids/fccs/examples.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/services/api/api/resources/grids/fbfm13/examples.py b/services/api/api/resources/grids/fbfm13/examples.py index 212e7b66..a40ebe3e 100644 --- a/services/api/api/resources/grids/fbfm13/examples.py +++ b/services/api/api/resources/grids/fbfm13/examples.py @@ -106,7 +106,9 @@ "summary": "Latest release", "description": ( "Fetches the latest FBFM13 release on demand from LANDFIRE " - "Product Service instead of a staged release." + "Product Service as it becomes available region by region — " + "more current landscape conditions than the staged national release. " + "See https://landfire.gov/data for the delivery schedule." ), }, } diff --git a/services/api/api/resources/grids/fbfm40/examples.py b/services/api/api/resources/grids/fbfm40/examples.py index 5296563f..cbacf9e2 100644 --- a/services/api/api/resources/grids/fbfm40/examples.py +++ b/services/api/api/resources/grids/fbfm40/examples.py @@ -122,7 +122,9 @@ "summary": "Latest release", "description": ( "Fetches the latest FBFM40 release on demand from LANDFIRE " - "Product Service instead of a staged release." + "Product Service as it becomes available region by region — " + "more current landscape conditions than the staged national release. " + "See https://landfire.gov/data for the delivery schedule." ), }, } diff --git a/services/api/api/resources/grids/fccs/examples.py b/services/api/api/resources/grids/fccs/examples.py index 9ff4e1ab..f60c6329 100644 --- a/services/api/api/resources/grids/fccs/examples.py +++ b/services/api/api/resources/grids/fccs/examples.py @@ -105,7 +105,9 @@ "summary": "Latest release", "description": ( "Fetches the latest FCCS release on demand from LANDFIRE " - "Product Service instead of a staged release." + "Product Service as it becomes available region by region — " + "more current landscape conditions than the staged national release. " + "See https://landfire.gov/data for the delivery schedule." ), }, }