From dc36c07cde3326f3fc73ab7fe5b4502e1710e22c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 10:34:45 -0600 Subject: [PATCH 1/2] Add create_irradiance_grid_from_leaflux for LeafLux solar irradiance grids Wrap the generated create_leaflux_irradiance_grid endpoint as a new solar/irradiance grid family. The function derives a 3D irradiance grid from a source 3D grid carrying a leaf_area_density band, at a UTC date_time, with an optional 2D terrain grid for surface irradiance, LeafluxBand output selection (defaulting to irradiance.surface.relative), and a Beer-Lambert extinction coefficient. Source grids are accepted as Grid objects or ids. Built against the current generated client, which serializes source_grid_id. The deployed API renamed this to source_lad_grid_id (86a5a67); the live create test is marked xfail until the client is regenerated. --- fastfuels_sdk/v2/grids.py | 106 +++++++++++++++++++++++ tests/v2/test_grids.py | 178 +++++++++++++++++++++++++++++++++++++- 2 files changed, 283 insertions(+), 1 deletion(-) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 07dd0a9..0fe83c3 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -3,6 +3,7 @@ """ # Core imports +import datetime import json import re from collections.abc import Mapping @@ -30,6 +31,7 @@ create_landfire_fbfm40, create_landfire_fccs, create_landfire_topography, + create_leaflux_irradiance_grid, create_meta_chm, create_naip_chm, create_netcdf_upload, @@ -64,6 +66,7 @@ CreateLandfireFbfm40Request, CreateLandfireFccsRequest, CreateLandfireTopographyRequest, + CreateLeafluxIrradianceRequest, CreateMetaChmRequest, CreateNaipChmRequest, CreateNetcdfUploadRequest, @@ -94,6 +97,7 @@ LandfireFbfm40Version, LandfireFccsVersion, LandfireTopographyVersion, + LeafluxBand, ListGridsResponse, MetaCHMVersion, NonBurnableFuelModel, @@ -137,6 +141,7 @@ "create_fuel_grid_from_fccs_lookup", "create_fuel_grid_from_fbfm13_lookup", "create_fuel_grid_from_fbfm40_lookup", + "create_irradiance_grid_from_leaflux", "list_grids", "get_grid", "check_3dep_coverage", @@ -148,6 +153,11 @@ def _domain_id(domain) -> str: return getattr(domain, "id", domain) +def _grid_id(grid) -> str: + """Resolve a Grid object or a grid-id string to the id string.""" + return getattr(grid, "id", grid) + + def _enum_list(values, enum_cls): """Coerce a list of strings/enum members to enum members, or UNSET.""" if values is None: @@ -2190,6 +2200,102 @@ def _duet_integer(name: str, value, *, minimum: int, maximum: int) -> int: return value +def create_irradiance_grid_from_leaflux( + source_grid, + date_time: datetime.datetime, + source_terrain_grid=None, + bands: Optional[list] = None, + extinction_coefficient: float = 0.5, + domain=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a 3D LeafLux solar-irradiance grid from a source fuel grid. + + Computes solar irradiance through the canopy at a single instant, attenuating + light through the source grid's leaf area density with a Beer-Lambert + extinction coefficient. An optional 2D terrain grid supplies surface + elevation for the surface-irradiance band. + + Parameters + ---------- + source_grid : Grid or str + A completed 3D grid (or its id) carrying a ``leaf_area_density`` band. + date_time : datetime.datetime + The UTC instant at which to compute irradiance. + source_terrain_grid : Grid or str, optional + A 2D terrain grid (or its id) in the same domain, used for the + surface-irradiance band. + bands : list, optional + Irradiance bands to produce (``LeafluxBand`` members or their string + keys, e.g. "irradiance.surface.relative", + "irradiance.canopy.relative"). Defaults to + ``irradiance.surface.relative``. + extinction_coefficient : float, optional + Beer-Lambert extinction coefficient applied to the leaf area density + (default 0.5). + domain : Domain or str, optional + The domain (or its id) to create the grid in. Required only when + ``source_grid`` is given as an id; inferred from the grid otherwise. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + + Returns + ------- + Grid + The new pending irradiance Grid. + + Raises + ------ + ValueError + If ``source_grid`` is a Grid that is not completed or lacks a + ``leaf_area_density`` band, or if ``domain`` is required but not given. + """ + if isinstance(source_grid, Grid): + source_grid._require_completed("create a LeafLux irradiance grid from") + band_keys = [band.key for band in source_grid.bands] + if "leaf_area_density" not in band_keys: + raise ValueError( + f"Grid {source_grid.id} has no 'leaf_area_density' band; pass a " + f"3D grid with leaf area density. Available bands: {band_keys}." + ) + + domain_id = ( + _domain_id(domain) + if domain is not None + else getattr(source_grid, "domain_id", None) + ) + if domain_id is None: + raise ValueError( + "Pass source_grid as a Grid, or supply domain= when source_grid is " + "an id." + ) + + # The generated client (and the deployed API/spec it was built from) names + # this field source_grid_id. API commit 86a5a67 renames it to + # source_lad_grid_id; the client and this wrapper update on the next regen + # once the API rebuilds. + request_body = CreateLeafluxIrradianceRequest( + source_grid_id=_grid_id(source_grid), + date_time=date_time, + source_terrain_grid_id=( + _grid_id(source_terrain_grid) if source_terrain_grid is not None else UNSET + ), + bands=_enum_list(bands, LeafluxBand), + extinction_coefficient=extinction_coefficient, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_leaflux_irradiance_grid.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + # --------------------------------------------------------------------------- # Listing, fetching, and utilities # --------------------------------------------------------------------------- diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 7b1e01e..ba2dfd8 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -3,6 +3,7 @@ """ # Core imports +import datetime import inspect import json import math @@ -33,6 +34,7 @@ create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, create_grid_from_geotiff, + create_irradiance_grid_from_leaflux, create_pim_grid_from_treemap, create_surface_fuel_grid_from_duet, create_topography_grid_from_3dep, @@ -60,6 +62,7 @@ GridModificationCondition, GridSource, JobStatus, + LeafluxBand, Modifier, Operator, PointCloudType, @@ -68,7 +71,11 @@ UploadBandDefinition, ) from fastfuels_sdk.v2.client_library.types import UNSET, Response -from fastfuels_sdk.v2.exceptions import NotFoundException, expect +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, + expect, +) from fastfuels_sdk.v2.modifications import mask # External imports @@ -492,6 +499,175 @@ def test_create_live(self, completed_tree_inventory): voxels.delete() +class TestCreateIrradianceGridFromLeaflux: + @staticmethod + def _source_grid(status=JobStatus.COMPLETED, omit=False): + bands = ( + [] + if omit + else [Band(key="leaf_area_density", type_=BandType.CONTINUOUS, index=0)] + ) + return Grid( + id="lad-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=bands, + ) + + @staticmethod + def _patch(monkeypatch, captured): + created = Grid( + id="irradiance-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_leaflux_irradiance_grid, + "sync_detailed", + fake_create, + ) + return client + + def test_builds_request_from_completed_grid(self, monkeypatch): + captured = {} + client = self._patch(monkeypatch, captured) + when = datetime.datetime(2024, 7, 1, 18, 30, tzinfo=datetime.timezone.utc) + + grid = create_irradiance_grid_from_leaflux( + self._source_grid(), + date_time=when, + source_terrain_grid="terrain-grid-id", + bands=[ + "irradiance.canopy.relative", + LeafluxBand.IRRADIANCE_SURFACE_RELATIVE, + ], + extinction_coefficient=0.7, + name="leaflux irradiance", + tags=["test"], + ) + + assert grid.id == "irradiance-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].source_grid_id == "lad-grid-id" + assert captured["body"].date_time == when + assert captured["body"].source_terrain_grid_id == "terrain-grid-id" + assert captured["body"].bands == [ + LeafluxBand.IRRADIANCE_CANOPY_RELATIVE, + LeafluxBand.IRRADIANCE_SURFACE_RELATIVE, + ] + assert captured["body"].extinction_coefficient == 0.7 + assert captured["body"].name == "leaflux irradiance" + assert captured["body"].tags == ["test"] + + def test_defaults_leave_bands_and_terrain_unset(self, monkeypatch): + captured = {} + self._patch(monkeypatch, captured) + + create_irradiance_grid_from_leaflux( + self._source_grid(), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + assert captured["body"].bands is UNSET + assert captured["body"].source_terrain_grid_id is UNSET + assert captured["body"].extinction_coefficient == 0.5 + + def test_accepts_ids_with_explicit_domain(self, monkeypatch): + captured = {} + self._patch(monkeypatch, captured) + + create_irradiance_grid_from_leaflux( + "lad-grid-id", + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + source_terrain_grid=SimpleNamespace(id="terrain-grid-id"), + domain=SimpleNamespace(id="domain-id"), + ) + + assert captured["domain_id"] == "domain-id" + assert captured["body"].source_grid_id == "lad-grid-id" + assert captured["body"].source_terrain_grid_id == "terrain-grid-id" + + def test_requires_domain_for_id_source(self): + with pytest.raises(ValueError, match="domain="): + create_irradiance_grid_from_leaflux( + "lad-grid-id", + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + def test_requires_completed_source_grid(self): + with pytest.raises(ValueError, match=r"Call \.wait\(\)"): + create_irradiance_grid_from_leaflux( + self._source_grid(status=JobStatus.PENDING), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + def test_requires_leaf_area_density_band(self): + with pytest.raises(ValueError, match="leaf_area_density"): + create_irradiance_grid_from_leaflux( + self._source_grid(omit=True), + date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + ) + + # The deployed API renamed the request field to source_lad_grid_id + # (86a5a67), but the generated client still serializes source_grid_id, so + # the create call 422s until the client is regenerated. strict=False lets + # this xfail now and flip to passing once the client catches up. + @pytest.mark.xfail( + reason="generated client sends source_grid_id; deployed API expects " + "source_lad_grid_id (pending client regen)", + raises=UnprocessableEntityException, + strict=False, + ) + def test_create_live(self, completed_tree_inventory): + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2, + vertical_resolution_m=1, + bands=["leaf_area_density"], + name="test_leaflux_source", + tags=["test"], + ) + irradiance = None + try: + voxels.wait() + irradiance = create_irradiance_grid_from_leaflux( + voxels, + date_time=datetime.datetime( + 2024, 7, 1, 18, 0, tzinfo=datetime.timezone.utc + ), + bands=["irradiance.canopy.relative"], + name="test_leaflux_irradiance", + tags=["test"], + ) + irradiance.wait() + assert irradiance.status == JobStatus.COMPLETED + assert "irradiance.canopy.relative" in { + band.key for band in irradiance.bands + } + values = irradiance.to_numpy("irradiance.canopy.relative") + assert values.ndim == 3 + assert np.isfinite(values).any() + finally: + if irradiance is not None: + irradiance.delete() + voxels.delete() + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion From fcab8135b01e8e27885416d98036b56ed1c24e93 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 10:45:59 -0600 Subject: [PATCH 2/2] Regenerate leaflux client for the source_lad_grid_id rename The deployed API has adopted the source_grid_id -> source_lad_grid_id rename (API commit 86a5a67); regenerate the client (only the leaflux request model changed) and switch the wrapper to source_lad_grid_id. Drop the xfail on the leaflux live create test now that the field name matches the deployed API; the test passes end to end. --- .../create_leaflux_irradiance_request.py | 18 ++++++++++-------- fastfuels_sdk/v2/grids.py | 6 +----- tests/v2/test_grids.py | 15 ++------------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py b/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py index 42df6f9..6580e6e 100644 --- a/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_leaflux_irradiance_request.py @@ -21,18 +21,20 @@ class CreateLeafluxIrradianceRequest: grid's geometry, so there is no resolution input. Attributes: - source_grid_id (str): ID of a completed 3D grid that has a `leaf_area_density` band. + source_lad_grid_id (str): ID of the completed 3D fuel grid whose `leaf_area_density` (LAD) band drives the Beer- + Lambert light attenuation. This is the primary input the irradiance field is computed from. Named for the band + it consumes so it reads unambiguously alongside `source_terrain_grid_id`. date_time (datetime.datetime): UTC instant at which to compute irradiance. name (str | Unset): Default: ''. description (str | Unset): Default: ''. tags (list[str] | Unset): - source_terrain_grid_id (None | str | Unset): (optional) 2D terrain grid in the same domain, used for surface - irradiance. + source_terrain_grid_id (None | str | Unset): (optional) ID of a completed 2D terrain grid (with an `elevation` + band) in the same domain, used to drape the surface irradiance band over real terrain instead of a flat plane. bands (list[LeafluxBand] | Unset): Which output bands to produce. Defaults to `irradiance.surface.relative`. extinction_coefficient (float | Unset): Beer-Lambert extinction coefficient (leaflux `extn`). Default: 0.5. """ - source_grid_id: str + source_lad_grid_id: str date_time: datetime.datetime name: str | Unset = "" description: str | Unset = "" @@ -42,7 +44,7 @@ class CreateLeafluxIrradianceRequest: extinction_coefficient: float | Unset = 0.5 def to_dict(self) -> dict[str, Any]: - source_grid_id = self.source_grid_id + source_lad_grid_id = self.source_lad_grid_id date_time = self.date_time.isoformat() @@ -73,7 +75,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update( { - "source_grid_id": source_grid_id, + "source_lad_grid_id": source_lad_grid_id, "date_time": date_time, } ) @@ -95,7 +97,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) - source_grid_id = d.pop("source_grid_id") + source_lad_grid_id = d.pop("source_lad_grid_id") date_time = datetime.datetime.fromisoformat(d.pop("date_time")) @@ -128,7 +130,7 @@ def _parse_source_terrain_grid_id(data: object) -> None | str | Unset: extinction_coefficient = d.pop("extinction_coefficient", UNSET) create_leaflux_irradiance_request = cls( - source_grid_id=source_grid_id, + source_lad_grid_id=source_lad_grid_id, date_time=date_time, name=name, description=description, diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 0fe83c3..137e67d 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -2274,12 +2274,8 @@ def create_irradiance_grid_from_leaflux( "an id." ) - # The generated client (and the deployed API/spec it was built from) names - # this field source_grid_id. API commit 86a5a67 renames it to - # source_lad_grid_id; the client and this wrapper update on the next regen - # once the API rebuilds. request_body = CreateLeafluxIrradianceRequest( - source_grid_id=_grid_id(source_grid), + source_lad_grid_id=_grid_id(source_grid), date_time=date_time, source_terrain_grid_id=( _grid_id(source_terrain_grid) if source_terrain_grid is not None else UNSET diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index ba2dfd8..7abc5d9 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -73,7 +73,6 @@ from fastfuels_sdk.v2.client_library.types import UNSET, Response from fastfuels_sdk.v2.exceptions import ( NotFoundException, - UnprocessableEntityException, expect, ) from fastfuels_sdk.v2.modifications import mask @@ -564,7 +563,7 @@ def test_builds_request_from_completed_grid(self, monkeypatch): assert grid.id == "irradiance-grid-id" assert captured["domain_id"] == "domain-id" assert captured["client"] is client - assert captured["body"].source_grid_id == "lad-grid-id" + assert captured["body"].source_lad_grid_id == "lad-grid-id" assert captured["body"].date_time == when assert captured["body"].source_terrain_grid_id == "terrain-grid-id" assert captured["body"].bands == [ @@ -600,7 +599,7 @@ def test_accepts_ids_with_explicit_domain(self, monkeypatch): ) assert captured["domain_id"] == "domain-id" - assert captured["body"].source_grid_id == "lad-grid-id" + assert captured["body"].source_lad_grid_id == "lad-grid-id" assert captured["body"].source_terrain_grid_id == "terrain-grid-id" def test_requires_domain_for_id_source(self): @@ -624,16 +623,6 @@ def test_requires_leaf_area_density_band(self): date_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), ) - # The deployed API renamed the request field to source_lad_grid_id - # (86a5a67), but the generated client still serializes source_grid_id, so - # the create call 422s until the client is regenerated. strict=False lets - # this xfail now and flip to passing once the client catches up. - @pytest.mark.xfail( - reason="generated client sends source_grid_id; deployed API expects " - "source_lad_grid_id (pending client regen)", - raises=UnprocessableEntityException, - strict=False, - ) def test_create_live(self, completed_tree_inventory): voxels = completed_tree_inventory.voxelize( horizontal_resolution_m=2,