From 643e0f966d5790cc47d8cc5976c51215bb531d65 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 31 Jul 2025 16:20:37 +0100 Subject: [PATCH 1/9] Draft class for handling plant resource pools. --- .../models/animal/plant_resources_2.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 virtual_ecosystem/models/animal/plant_resources_2.py diff --git a/virtual_ecosystem/models/animal/plant_resources_2.py b/virtual_ecosystem/models/animal/plant_resources_2.py new file mode 100644 index 000000000..96b7017d6 --- /dev/null +++ b/virtual_ecosystem/models/animal/plant_resources_2.py @@ -0,0 +1,70 @@ +"""The ''plant_resources'' classes provides toy plant module functionality that are +required for setting up and testing the early stages of the animal module. +""" # noqa: D205 + +from virtual_ecosystem.core.data import Data + +# from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy +from virtual_ecosystem.models.animal.protocols import Consumer + + +class PlantResourcePool: + """A class to track plant biomass resources in a given grid cell. + + This generic class reads a plant biomass value from the `data` object and stores + current mass for resource use (e.g. herbivory). + + Args: + cell_id: The grid cell ID. + data: The Data object containing the variable. + variable_name: The name of the data variable in `data`. + cell_area: Area of the grid cell in m² (used if data is in per-area units). + """ + + def __init__( + self, + cell_id: int, + data: "Data", + variable_name: str, + cell_area: float, + ) -> None: + self.cell_id = cell_id + self.variable_name = variable_name + + # Get per-area biomass value and convert to absolute mass + biomass_per_area = data[variable_name].sel(cell_id=cell_id).item() + self.mass_current: float = biomass_per_area * cell_area + + if self.mass_current < 0: + raise ValueError( + f"{variable_name}: negative biomass value in cell {cell_id}: " + f"{self.mass_current} kg" + ) + + def get_eaten( + self, + consumed_mass: float, + consumer: "Consumer", + ) -> tuple[dict[str, float], dict[str, float]]: + """Simulate consumption of the plant resource by a consumer. + + Args: + consumed_mass: Target wet-mass to consume **after** mechanical efficiency is + applied (kg). + consumer: The AnimalCohort or similar consumer. + + Returns: + Tuple of: + - dict of element masses assimilated by consumer (currently just 'carbon') + - dict of losses (currently empty). + """ + if consumed_mass < 0: + raise ValueError("consumed_mass must be non-negative") + + mech_eff = consumer.functional_group.mechanical_efficiency + actual = min(consumed_mass, self.mass_current) * mech_eff + + # Simple carbon-only return for now + taken = {"carbon": actual} + self.mass_current -= actual + return taken, {} From 2e950ff92a8110597c9e2024fdd56e7fee0de24d Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 1 Aug 2025 16:31:38 +0100 Subject: [PATCH 2/9] Revised new plant resource pool class and added populate method. --- .../models/animal/animal_model.py | 36 +++++-- .../models/animal/plant_resources_2.py | 101 ++++++++++-------- 2 files changed, 83 insertions(+), 54 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index c0c88164e..7595539b5 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -56,7 +56,7 @@ FunctionalGroup, get_functional_group_by_name, ) -from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.plant_resources_2 import PlantResourcePool from virtual_ecosystem.models.animal.protocols import Resource from virtual_ecosystem.models.animal.scaling_functions import ( damuths_law, @@ -410,14 +410,8 @@ def _setup( """Determine grid square adjacency.""" self.functional_groups = functional_groups self.model_constants = self.model_constants - self.plant_resources = { - cell_id: [ - PlantResources( - data=self.data, cell_id=cell_id, constants=self.model_constants - ) - ] - for cell_id in self.data.grid.cell_id - } + # self.plant_resources = self.populate_plant_resource_pools() + # TODO - In future, need to take in data on average size of excrement and # carcasses pools and their stoichiometries for the initial scavengeable pool # parameterisations @@ -683,6 +677,30 @@ def populate_fungal_fruiting_bodies(self) -> dict[int, FungalFruitPool]: for cell_id in self.data.grid.cell_id } + def populate_plant_resource_pools(self) -> dict[int, dict[str, PlantResourcePool]]: + """Populate plant resources by cell and functional group. + + Each PlantResourcePool tracks all biomass types for one functional group in + one grid cell. + """ + + plant_resources: dict[int, dict[str, PlantResourcePool]] = {} + + for cell_id in self.data.grid.cell_id: + plant_resources[cell_id] = {} + + for fg in self.functional_groups: + fg_name = fg.name + pool = PlantResourcePool( + cell_id=cell_id, + functional_group_name=fg_name, + data=self.data, + cell_area=self.data.grid.cell_area, + ) + plant_resources[cell_id][fg_name] = pool + + return plant_resources + def calculate_total_litter_consumption( self, litter_pools: dict[int, dict[str, Resource]] ) -> dict[str, DataArray]: diff --git a/virtual_ecosystem/models/animal/plant_resources_2.py b/virtual_ecosystem/models/animal/plant_resources_2.py index 96b7017d6..6eec26ed6 100644 --- a/virtual_ecosystem/models/animal/plant_resources_2.py +++ b/virtual_ecosystem/models/animal/plant_resources_2.py @@ -3,68 +3,79 @@ """ # noqa: D205 from virtual_ecosystem.core.data import Data - -# from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy +from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy from virtual_ecosystem.models.animal.protocols import Consumer class PlantResourcePool: - """A class to track plant biomass resources in a given grid cell. - - This generic class reads a plant biomass value from the `data` object and stores - current mass for resource use (e.g. herbivory). - - Args: - cell_id: The grid cell ID. - data: The Data object containing the variable. - variable_name: The name of the data variable in `data`. - cell_area: Area of the grid cell in m² (used if data is in per-area units). - """ + """Tracks plant resources for a specific functional group in one grid cell.""" def __init__( self, cell_id: int, + functional_group_name: str, data: "Data", - variable_name: str, cell_area: float, ) -> None: self.cell_id = cell_id - self.variable_name = variable_name + self.functional_group_name = functional_group_name + self.vertical_occupancy = VerticalOccupancy.CANOPY # or GROUND, etc. + + # Optional fields, default to zero if missing + self.leaf_mass = ( + data["llayer_leaf_mass"] + .sel(cell_id=cell_id, plant_functional_type=functional_group_name) + .item() + * cell_area + if "llayer_leaf_mass" in data + else 0.0 + ) + + self.canopy_n_propagules = ( + data["canopy_n_propagules"] + .sel(cell_id=cell_id, plant_functional_type=functional_group_name) + .item() + if "canopy_n_propagules" in data + else 0.0 + ) + + self.fallen_n_propagules = ( + data["fallen_n_propagules"] + .sel(cell_id=cell_id, plant_functional_type=functional_group_name) + .item() + if "fallen_n_propagules" in data + else 0.0 + ) - # Get per-area biomass value and convert to absolute mass - biomass_per_area = data[variable_name].sel(cell_id=cell_id).item() - self.mass_current: float = biomass_per_area * cell_area + self.subcanopy_veg_mass = ( + data["subcanopy_vegetation_biomass"] + .sel(cell_id=cell_id, plant_functional_type=functional_group_name) + .item() + * cell_area + if "subcanopy_vegetation_biomass" in data + else 0.0 + ) - if self.mass_current < 0: - raise ValueError( - f"{variable_name}: negative biomass value in cell {cell_id}: " - f"{self.mass_current} kg" - ) + self.seedbank_mass = ( + data["subcanopy_seedbank_biomass"] + .sel(cell_id=cell_id, plant_functional_type=functional_group_name) + .item() + * cell_area + if "subcanopy_seedbank_biomass" in data + else 0.0 + ) + + @property + def mass_current(self) -> float: + """Total available plant biomass for this pool.""" + return self.leaf_mass + self.subcanopy_veg_mass def get_eaten( self, consumed_mass: float, consumer: "Consumer", ) -> tuple[dict[str, float], dict[str, float]]: - """Simulate consumption of the plant resource by a consumer. - - Args: - consumed_mass: Target wet-mass to consume **after** mechanical efficiency is - applied (kg). - consumer: The AnimalCohort or similar consumer. - - Returns: - Tuple of: - - dict of element masses assimilated by consumer (currently just 'carbon') - - dict of losses (currently empty). - """ - if consumed_mass < 0: - raise ValueError("consumed_mass must be non-negative") - - mech_eff = consumer.functional_group.mechanical_efficiency - actual = min(consumed_mass, self.mass_current) * mech_eff - - # Simple carbon-only return for now - taken = {"carbon": actual} - self.mass_current -= actual - return taken, {} + """Placeholder to satisfy the Resource protocol.""" + raise NotImplementedError( + "Resource-specific get_eaten logic not yet implemented." + ) From accdc3b8e2dba1202bfd780079b7d723711f00bd Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 4 Aug 2025 15:21:18 +0100 Subject: [PATCH 3/9] Fixed spelling error in data call. --- virtual_ecosystem/models/animal/plant_resources_2.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/virtual_ecosystem/models/animal/plant_resources_2.py b/virtual_ecosystem/models/animal/plant_resources_2.py index 6eec26ed6..e5d9b4d69 100644 --- a/virtual_ecosystem/models/animal/plant_resources_2.py +++ b/virtual_ecosystem/models/animal/plant_resources_2.py @@ -19,15 +19,15 @@ def __init__( ) -> None: self.cell_id = cell_id self.functional_group_name = functional_group_name - self.vertical_occupancy = VerticalOccupancy.CANOPY # or GROUND, etc. + self.vertical_occupancy = VerticalOccupancy.CANOPY # Optional fields, default to zero if missing self.leaf_mass = ( - data["llayer_leaf_mass"] + data["layer_leaf_mass"] .sel(cell_id=cell_id, plant_functional_type=functional_group_name) .item() * cell_area - if "llayer_leaf_mass" in data + if "layer_leaf_mass" in data else 0.0 ) From 3d59ba421b67a15c5775480f99907205a71b59a7 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 4 Aug 2025 16:35:59 +0100 Subject: [PATCH 4/9] Added an aggregated plant resource class option to draft. --- .../models/animal/plant_resources_2.py | 2 +- .../models/animal/plant_resources_3.py | 107 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 virtual_ecosystem/models/animal/plant_resources_3.py diff --git a/virtual_ecosystem/models/animal/plant_resources_2.py b/virtual_ecosystem/models/animal/plant_resources_2.py index e5d9b4d69..f50d698d4 100644 --- a/virtual_ecosystem/models/animal/plant_resources_2.py +++ b/virtual_ecosystem/models/animal/plant_resources_2.py @@ -8,7 +8,7 @@ class PlantResourcePool: - """Tracks plant resources for a specific functional group in one grid cell.""" + """Resource pool aggregating plant mass by functional type.""" def __init__( self, diff --git a/virtual_ecosystem/models/animal/plant_resources_3.py b/virtual_ecosystem/models/animal/plant_resources_3.py new file mode 100644 index 000000000..9d459e354 --- /dev/null +++ b/virtual_ecosystem/models/animal/plant_resources_3.py @@ -0,0 +1,107 @@ +"""The ''plant_resources'' classes provides toy plant module functionality that are +required for setting up and testing the early stages of the animal module. +""" # noqa: D205 + +from virtual_ecosystem.core.data import Data +from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy +from virtual_ecosystem.models.animal.protocols import Consumer + + +class AggregatedPlantResource: + """Resource pool aggregating plant mass by resource type.""" + + def __init__( + self, + cell_id: int, + resource_name: str, + data: "Data", + functional_types: list[str], + cell_area: float, + vertical_occupancy: VerticalOccupancy, + variable_name: str, + ) -> None: + self.cell_id = cell_id + self.resource_name = resource_name + self.vertical_occupancy = vertical_occupancy + + self.mass_by_fg: dict[str, float] = {} + + for fg in functional_types: + if variable_name not in data: + continue + + try: + per_area = ( + data[variable_name] + .sel(cell_id=cell_id, plant_functional_type=fg) + .item() + ) + self.mass_by_fg[fg] = per_area * cell_area + except KeyError: + continue + + @property + def mass_current(self) -> float: + """Return total biomass across all contributing functional groups.""" + return sum(self.mass_by_fg.values()) + + def get_eaten( + self, + consumed_mass: float, + consumer: "Consumer", + ) -> tuple[dict[str, float], dict[str, float]]: + """Placeholder to satisfy the Resource protocol.""" + raise NotImplementedError( + "Resource-specific get_eaten logic not yet implemented." + ) + + +# The following belongs in AnimalModel +""" def populate_aggregated_plant_resources( + data: "Data", + grid: "Grid", + functional_groups: list[str], + cell_area: float, +) -> dict[int, dict[str, AggregatedPlantResource]]: + + + resource_definitions = { + "leaves": { + "variable_name": "layer_leaf_mass", + "vertical_occupancy": VerticalOccupancy.CANOPY, + }, + "seeds": { + "variable_name": "fallen_n_propagules", + "vertical_occupancy": VerticalOccupancy.GROUND, + }, + "subcanopy_veg": { + "variable_name": "subcanopy_vegetation_biomass", + "vertical_occupancy": VerticalOccupancy.GROUND, + }, + "seedbank": { + "variable_name": "subcanopy_seedbank_biomass", + "vertical_occupancy": VerticalOccupancy.SOIL, + }, + } + + resources: dict[int, dict[str, AggregatedPlantResource]] = {} + + for cell_id in grid.cell_id: + cell_resources = {} + + for name, info in resource_definitions.items(): + pool = AggregatedPlantResource( + cell_id=cell_id, + resource_name=name, + data=data, + functional_groups=[fg.name for fg in functional_groups], + cell_area=cell_area, + vertical_occupancy=info["vertical_occupancy"], + variable_name=info["variable_name"], + ) + cell_resources[name] = pool + + resources[cell_id] = cell_resources + + return resources + """ From b96ee58db78fc8b00b5e5bdc35e32b2fd7b51e71 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 12 Aug 2025 14:53:19 +0100 Subject: [PATCH 5/9] Added a mushroom functionality to animal cohort class. --- .../models/animal/animal_cohorts.py | 58 +++++++++++++++---- .../models/animal/animal_model.py | 3 +- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index b3325f43b..a096229ab 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -699,32 +699,35 @@ def calculate_total_handling_time_for_herbivory( for plant in plant_list ) - def F_i_k(self, plant_list: list[Resource], target_plant: Resource) -> float: - """Method to determine instantaneous herbivory rate on plant k. + def F_i_k(self, resource_list: list[Resource], target_resource: Resource) -> float: + """Method to determine instantaneous consumption rate on resource k. This method integrates the calculated search efficiency, potential consumed biomass of the target plant, and the total handling time for all available - plant resources to determine the rate at which the target plant is consumed by + resources to determine the rate at which the target plant is consumed by the cohort. + This method is originally parameterized for herbivory but is currently used for + all non-predation consumer-resource interactions. + TODO: update name Args: - plant_list: A list of plant resources available for consumption by the + resource_list: A list of plant resources available for consumption by the cohort. - target_plant: The specific plant resource being targeted by the herbivore + target_resource: The specific resource being targeted by the herbivore cohort for consumption. Returns: - The instantaneous consumption rate [g/day] of the target plant resource by - the herbivore cohort. + The instantaneous consumption rate [g/day] of the target resource by + the consumer cohort. """ alpha = self.calculate_alpha() - k = self.calculate_potential_consumed_biomass(target_plant, alpha) + k = self.calculate_potential_consumed_biomass(target_resource, alpha) total_handling_t = self.calculate_total_handling_time_for_herbivory( - plant_list, alpha + resource_list, alpha ) - B_k = target_plant.mass_current # current plant biomass + B_k = target_resource.mass_current # current plant biomass N = self.individuals # herb cohort size return N * (k / (1 + total_handling_t)) * (1 / B_k) @@ -1073,10 +1076,34 @@ def delta_mass_excrement_scavenging( calculate_consumed_mass=self.default_consumed_resource_mass, ) + def delta_mass_fruiting_fungivory( + self, + mushroom_list: list[Resource], + adjusted_dt: timedelta64, + herbivory_waste_pools: dict[int, HerbivoryWaste], + ) -> dict[str, float]: + """Handle mass assimilation from fruiting body (mushroom) fungivory. + + Args: + mushroom_list: List of fungal fruiting resources. + adjusted_dt: Time available for foraging. + herbivory_waste_pools: Waste pools for unassimilated fungal matter. + + Returns: + Stoichiometric mass gained by the cohort. + """ + return self.forage_resource_list( + resources=mushroom_list, + adjusted_dt=adjusted_dt, + calculate_consumed_mass=self.default_consumed_resource_mass, + herbivory_waste_pools=herbivory_waste_pools, + ) + def forage_cohort( self, plant_list: list[Resource], animal_list: list[AnimalCohort], + mushroom_list: list[Resource], litter_pools: list[Resource], excrement_pools: list[ExcrementPool], carcass_pool_map: dict[int, list[CarcassPool]], @@ -1097,6 +1124,7 @@ def forage_cohort( Args: plant_list: Live plant resources available for herbivory. animal_list: Live prey cohorts available for predation. + mushroom_list: Live fungal fruiting bodies available for consumption. litter_pools: LitterPool objects available for detritivory. excrement_pools: ExcrementPool objects used for defecation deposition. @@ -1150,6 +1178,16 @@ def forage_cohort( for k in total_gain: total_gain[k] += gain[k] + # live mushroom fungivory + if mushroom_list: + gain = self.delta_mass_fruiting_fungivory( + mushroom_list=mushroom_list, + adjusted_dt=time_available_per_diet, + herbivory_waste_pools=herbivory_waste_pools, + ) + for k in total_gain: + total_gain[k] += gain[k] + # litter detritivory if litter_pools: gain = self.delta_mass_detritivory( diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 7595539b5..88f429951 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -1462,6 +1462,7 @@ def forage_community(self, dt: timedelta64) -> None: # Build resource collections based on diet flags plant_list: list[Resource] = [] prey_list: list[AnimalCohort] = [] + # mushroom_list: list[Resource] = [] litter_list: list[Resource] = [] scavenge_carcass_pools: list[Resource] = [] scavenge_waste_pools: list[Resource] = [] @@ -1476,7 +1477,6 @@ def forage_community(self, dt: timedelta64) -> None: | DietType.FLOWERS | DietType.FOLIAGE | DietType.FRUIT - | DietType.FUNGUS | DietType.SEEDS | DietType.NECTAR | DietType.WOOD @@ -1509,6 +1509,7 @@ def forage_community(self, dt: timedelta64) -> None: cohort.forage_cohort( plant_list=plant_list, animal_list=prey_list, + mushroom_list=[], litter_pools=litter_list, excrement_pools=excrement_pools, # for defecation carcass_pool_map=carcass_pool_map, # for prey remains From f0db6fe3a05d6bbab909e8da83508a34ee3a9df0 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 13 Aug 2025 15:12:57 +0100 Subject: [PATCH 6/9] Changed get_X_resource methods of AnimalCohort to all use a single get_resource_in_territory method. --- .../models/animal/animal_cohorts.py | 140 +++++++++--------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 526fca43f..52a8b1065 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -6,7 +6,7 @@ import uuid from _collections_abc import Callable from math import ceil, exp, sqrt -from typing import Literal +from typing import Literal, TypeVar from numpy import timedelta64 @@ -25,6 +25,8 @@ from virtual_ecosystem.models.animal.functional_group import FunctionalGroup from virtual_ecosystem.models.animal.protocols import Resource +_T = TypeVar("_T", covariant=False) + class AnimalCohort: """This is a class of animal cohorts.""" @@ -1092,7 +1094,7 @@ def delta_mass_fruiting_fungivory( return self.forage_resource_list( resources=mushroom_list, adjusted_dt=adjusted_dt, - calculate_consumed_mass=self.default_consumed_resource_mass, + calculate_consumed_mass=self._consumed_resource_mass, herbivory_waste_pools=herbivory_waste_pools, ) @@ -1459,103 +1461,82 @@ def can_forage_on(self, resource: Resource) -> bool: """ return self.match_vertical(resource.vertical_occupancy) - def get_plant_resources( - self, plant_resources: dict[int, list[Resource]] - ) -> list[Resource]: - """Return plant resources accessible within this cohort's territory. + def _get_resources_in_territory( + self, + resource_map: dict[int, list[_T]], + filter_fn: Callable[[_T], bool] | None = None, + ) -> list[_T]: + """Generic method to retrieve resources from territory with optional filtering. Args: - plant_resources: Dictionary of plant resources keyed by grid cell IDs. + resource_map: Dictionary keyed by cell_id, values can be individual + resources or lists. + filter_fn: Optional callable to filter the resources. Returns: - List of accessible Resource objects within the territory. + Flat list of resources in the cohort's territory. """ - plant_resources_in_territory: list[Resource] = [] + result: list[_T] = [] - # Iterate over all grid cell keys in this territory for cell_id in self.territory: - # Check if the cell_id is within the provided plant resources - if cell_id in plant_resources: - for resource in plant_resources[cell_id]: - if self.can_forage_on(resource): - plant_resources_in_territory.append(resource) + if cell_id in resource_map: + entries = resource_map[cell_id] + if filter_fn: + entries = [r for r in entries if filter_fn(r)] + result.extend(entries) - return plant_resources_in_territory + return result - def get_excrement_pools( - self, excrement_pools: dict[int, list[ExcrementPool]] - ) -> list[ExcrementPool]: - """Returns a list of excrement pools in this territory. + def get_plant_resources( + self, plant_resources: dict[int, list[Resource]] + ) -> list[Resource]: + """Return plant resources accessible within this cohort's territory. - This method checks which grid cells are within this territory - and returns a list of the excrement pools available in those grid cells. + This method filters the plant resources by territory and the cohort's + foraging capability (via `can_forage_on`). Args: - excrement_pools: A dictionary of excrement pools where keys are grid - cell IDs. + plant_resources: A dictionary mapping cell IDs to lists of plant + resource objects. Returns: - A list of ExcrementPool objects in this territory. + A list of plant Resource objects that the cohort can forage on. """ - excrement_pools_in_territory: list[ExcrementPool] = [] + return self._get_resources_in_territory(plant_resources, self.can_forage_on) - # Iterate over all grid cell keys in this territory - for cell_id in self.territory: - # Check if the cell_id is within the provided excrement pools - if cell_id in excrement_pools: - excrement_pools_in_territory.extend(excrement_pools[cell_id]) - - return excrement_pools_in_territory - - def get_herbivory_waste_pools( - self, plant_waste: dict[int, HerbivoryWaste] - ) -> list[HerbivoryWaste]: - """Returns a list of herbivory waste pools in this territory. + def get_excrement_pools( + self, excrement_pools: dict[int, list[ExcrementPool]] + ) -> list[ExcrementPool]: + """Return excrement pools within the cohort's territory. - This method checks which grid cells are within this territory - and returns a list of the herbivory waste pools available in those grid cells. + This method returns all ExcrementPool objects that are located in grid + cells occupied by the cohort. Args: - plant_waste: A dictionary of herbivory waste pools where keys are grid - cell IDs. + excrement_pools: A dictionary mapping cell IDs to lists of ExcrementPool + objects. Returns: - A list of HerbivoryWaste objects in this territory. + A list of ExcrementPool objects in the cohort's territory. """ - plant_waste_pools_in_territory: list[HerbivoryWaste] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.territory: - # Check if the cell_id is within the provided herbivory waste pools - if cell_id in plant_waste: - plant_waste_pools_in_territory.append(plant_waste[cell_id]) - - return plant_waste_pools_in_territory + return self._get_resources_in_territory(excrement_pools) def get_carcass_pools( self, carcass_pools: dict[int, list[CarcassPool]] ) -> list[CarcassPool]: - """Returns a list of carcass pools in this territory. + """Return carcass pools within the cohort's territory. - This method checks which grid cells are within this territory - and returns a list of the carcass pools available in those grid cells. + This method returns all CarcassPool objects located in grid cells + that the cohort occupies. Args: - carcass_pools: A dictionary of carcass pools where keys are grid - cell IDs. + carcass_pools: A dictionary mapping cell IDs to lists of CarcassPool + objects. Returns: - A list of CarcassPool objects in this territory. + A list of CarcassPool objects in the cohort's territory. """ - carcass_pools_in_territory: list[CarcassPool] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.territory: - # Check if the cell_id is within the provided carcass pools - if cell_id in carcass_pools: - carcass_pools_in_territory.extend(carcass_pools[cell_id]) - - return carcass_pools_in_territory + return self._get_resources_in_territory(carcass_pools) def find_intersecting_carcass_pools( self, @@ -1577,6 +1558,31 @@ def find_intersecting_carcass_pools( intersecting_carcass_pools.extend(carcass_pools[cell_id]) return intersecting_carcass_pools + def get_herbivory_waste_pools( + self, plant_waste: dict[int, HerbivoryWaste] + ) -> list[HerbivoryWaste]: + """Returns a list of herbivory waste pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the herbivory waste pools available in those grid cells. + + Args: + plant_waste: A dictionary of herbivory waste pools where keys are grid + cell IDs. + + Returns: + A list of HerbivoryWaste objects in this territory. + """ + plant_waste_pools_in_territory: list[HerbivoryWaste] = [] + + # Iterate over all grid cell keys in this territory + for cell_id in self.territory: + # Check if the cell_id is within the provided herbivory waste pools + if cell_id in plant_waste: + plant_waste_pools_in_territory.append(plant_waste[cell_id]) + + return plant_waste_pools_in_territory + def is_migration_season(self) -> bool: """Handles determination of whether it is time to migrate. From aaf6266b88e46797b05cb325e9e3c78a8ea95faf Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 15 Aug 2025 16:36:14 +0100 Subject: [PATCH 7/9] Added mushrooms to foraging. --- tests/models/animals/conftest.py | 66 +++++++++++++++++++ .../data/example_functional_group_import.csv | 1 + tests/models/animals/test_animal_cohorts.py | 38 ++++++++--- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index e0be49e57..24c183b8b 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -787,6 +787,39 @@ def herbivore_cohort_instance( ) +@pytest.fixture +def fungivore_functional_group_instance(shared_datadir, constants_instance): + """Fixture for an animal functional group used in tests.""" + from virtual_ecosystem.models.animal.functional_group import ( + import_functional_groups, + ) + + file = shared_datadir / "example_functional_group_import.csv" + fg_list = import_functional_groups(file, constants_instance) + + return fg_list[16] + + +@pytest.fixture +def fungivore_cohort_instance( + fungivore_functional_group_instance, + animal_data_for_cohorts_instance, + constants_instance, +): + """Fixture for an animal cohort used in tests.""" + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + + return AnimalCohort( + fungivore_functional_group_instance, + 10000.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, + ) + + @pytest.fixture def predator_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" @@ -1108,3 +1141,36 @@ def herbivory_waste_pool_instance(): ) return herbivory_waste + + +@pytest.fixture +def mushroom_instance(litter_soil_data_instance): + """Fixture for a single FungalFruitPool object.""" + from virtual_ecosystem.models.animal.decay import ( + FungalFruitPool, + ) # Adjust as needed + + return FungalFruitPool( + cell_id=0, + data=litter_soil_data_instance, + cell_area=100.0, # m² + c_n_ratio=25.0, + c_p_ratio=100.0, + ) + + +@pytest.fixture +def mushroom_list_instance(litter_soil_data_instance): + """Fixture for multiple FungalFruitPool objects across grid cells.""" + from virtual_ecosystem.models.animal.decay import FungalFruitPool + + return [ + FungalFruitPool( + cell_id=cell_id, + data=litter_soil_data_instance, + cell_area=100.0, + c_n_ratio=25.0, + c_p_ratio=100.0, + ) + for cell_id in litter_soil_data_instance.grid.cell_id + ] diff --git a/tests/models/animals/data/example_functional_group_import.csv b/tests/models/animals/data/example_functional_group_import.csv index c7fd3ea11..17c630274 100644 --- a/tests/models/animals/data/example_functional_group_import.csv +++ b/tests/models/animals/data/example_functional_group_import.csv @@ -15,3 +15,4 @@ earthworm,invertebrate,detritus,ectothermic,terrestrial,iteroparous,direct,adult dung_beetle,invertebrate,waste,ectothermic,terrestrial,iteroparous,direct,adult,dung_beetle,uricotelic,none,soil_ground,0.0003,0.003, 0.005 scavenging_mammal,mammal,carcasses,endothermic,terrestrial,iteroparous,direct,adult,scavenging_mammal,ureotelic,none,ground,2.0,20.0, 0.005 detritivorous_insect,invertebrate,detritus,ectothermic,terrestrial,iteroparous,direct,adult,detritivorous_insect,uricotelic,none,soil_ground,0.0004,0.004, 0.005 +fungivorous_mammal,mammal,fungus,endothermic,terrestrial,iteroparous,direct,adult,fungivorous_mammal,ureotelic,none,soil_ground,1.0,10.0, 0.005 diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 65f13a652..21c2c57ac 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -1864,14 +1864,15 @@ def test_delta_mass_excrement_scavenging_calls_forage_resource_list( assert result == {"carbon": 4.0, "nitrogen": 1.0, "phosphorus": 0.5} @pytest.mark.parametrize( - "cohort_instance, diet_type, plant_list, animal_list, expected_nutrient_gain," - "delta_mass_mock", + "cohort_instance, diet_type, plant_list, animal_list, mushroom_list," + " expected_nutrient_gain, delta_mass_mock", [ ( "herbivore_cohort_instance", "HERBIVORE", "plant_list_instance", [], + [], {"carbon": 60.0, "nitrogen": 30.0, "phosphorus": 10.0}, "delta_mass_herbivory", ), @@ -1880,10 +1881,21 @@ def test_delta_mass_excrement_scavenging_calls_forage_resource_list( "CARNIVORE", [], "animal_list_instance", + [], {"carbon": 120.0, "nitrogen": 60.0, "phosphorus": 20.0}, "delta_mass_predation", ), + ( + "fungivore_cohort_instance", + "FUNGUS", + [], + [], + "mushroom_list_instance", + {"carbon": 25.0, "nitrogen": 5.0, "phosphorus": 2.5}, + "delta_mass_fruiting_fungivory", + ), ], + ids=["herbivore", "carnivore", "fungivore"], ) def test_forage_cohort( self, @@ -1893,10 +1905,12 @@ def test_forage_cohort( diet_type, plant_list, animal_list, + mushroom_list, expected_nutrient_gain, delta_mass_mock, plant_list_instance, animal_list_instance, + mushroom_list_instance, excrement_pool_instance, carcass_pools_by_cell_instance, herbivory_waste_pool_instance, @@ -1913,6 +1927,8 @@ def test_forage_cohort( plant_list = request.getfixturevalue(plant_list) if isinstance(animal_list, str): animal_list = request.getfixturevalue(animal_list) + if isinstance(mushroom_list, str): + mushroom_list = request.getfixturevalue(mushroom_list) # Construct herbivory waste pools if herbivore herbivory_waste_pools = { @@ -1924,7 +1940,6 @@ def test_forage_cohort( mock_delta_mass = mocker.patch.object( cohort, delta_mass_mock, return_value=expected_nutrient_gain ) - mock_eat = mocker.patch.object(cohort, "eat") # Dummy values for untested inputs empty_list = [] @@ -1933,8 +1948,9 @@ def test_forage_cohort( cohort.forage_cohort( plant_list=plant_list, animal_list=animal_list, + mushroom_list=mushroom_list, litter_pools=empty_list, - excrement_pools=excrement_pool_instance, + excrement_pools=[excrement_pool_instance], carcass_pool_map=carcass_pools_by_cell_instance, scavenge_carcass_pools=empty_list, scavenge_excrement_pools=empty_list, @@ -1953,15 +1969,17 @@ def test_forage_cohort( assert kwargs["herbivory_waste_pools"] == herbivory_waste_pools assert isinstance(kwargs["adjusted_dt"], int | float) - else: + elif diet_type == "CARNIVORE": assert kwargs["animal_list"] == animal_list_instance assert kwargs["carcass_pools"] == carcass_pools_by_cell_instance assert isinstance(kwargs["adjusted_dt"], int | float) - # Validate assimilation call - mock_eat.assert_called_once_with( - expected_nutrient_gain, excrement_pool_instance - ) + elif diet_type == "FUNGUS": + assert kwargs["mushroom_list"] == mushroom_list_instance + assert isinstance(kwargs["adjusted_dt"], int | float) + + else: + assert False, f"Unhandled diet_type: {diet_type}" def test_forage_cohort_skips_when_no_individuals( self, mocker, herbivore_cohort_instance @@ -1981,6 +1999,7 @@ def test_forage_cohort_skips_when_no_individuals( cohort.forage_cohort( plant_list=[], animal_list=[], + mushroom_list=[], litter_pools=[], excrement_pools=[], carcass_pool_map={}, @@ -2011,6 +2030,7 @@ def test_forage_cohort_skips_when_no_mass(self, mocker, herbivore_cohort_instanc cohort.forage_cohort( plant_list=[], animal_list=[], + mushroom_list=[], litter_pools=[], excrement_pools=[], carcass_pool_map={}, From 03f7f1739b793a3716c836a77d69b23c49517527 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 22 Aug 2025 09:03:56 +0100 Subject: [PATCH 8/9] Added fungus into prey selection and updated tests. --- tests/models/animals/test_animal_model.py | 22 +++++++++++++ .../models/animals/test_scaling_functions.py | 32 +++++++++++++++++-- .../models/animal/scaling_functions.py | 2 ++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 4802fbaa5..91bbc9a10 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -2087,6 +2087,28 @@ def test_forage_community( "predator": predator_cohort_instance, } + # Show where AnimalModel comes from (guards against stray imports) + import inspect + + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Comment: make failures descriptive and short to read in pytest output. + assert hasattr(animal_model_instance, "plant_resources"), ( + "plant_resources missing. " + f"_setup defined at: {inspect.getsourcefile(AnimalModel._setup)}:" + f"{inspect.getsourcelines(AnimalModel._setup)[1]} | " + f"attrs={sorted(vars(animal_model_instance))}" + ) + + # Optional: verify other _setup outputs exist; helps pinpoint where it failed. + for name in ( + "excrement_pools", + "carcass_pools", + "leaf_waste_pools", + "litter_pools", + ): + assert hasattr(animal_model_instance, name), f"Missing {name}" + # Run the forage_community method animal_model_instance.forage_community(dt=30) diff --git a/tests/models/animals/test_scaling_functions.py b/tests/models/animals/test_scaling_functions.py index 17fdfbb41..abbe29ca3 100644 --- a/tests/models/animals/test_scaling_functions.py +++ b/tests/models/animals/test_scaling_functions.py @@ -160,6 +160,7 @@ def test_herbivore_prey_group_selection(functional_group_list_instance): expected = { "plants": (0.0, 0.0), "litter": (0.0, 0.0), + "fungal_fruiting_bodies": (0.0, 0.0), } assert result == expected @@ -191,12 +192,29 @@ def test_carnivore_prey_group_selection(functional_group_list_instance): "detritivorous_insect": (0.0001, 1000.0), "dung_beetle": (0.0001, 1000.0), "scavenging_mammal": (0.0001, 1000.0), + "fungivorous_mammal": (0.0001, 1000.0), "carcasses": (0.0, 0.0), "excrement": (0.0, 0.0), } assert result == expected_output +def test_fungivore_prey_group_selection(functional_group_list_instance): + """Test for fungivore diet type selection.""" + from virtual_ecosystem.models.animal.scaling_functions import ( + DietType, + prey_group_selection, + ) + + result = prey_group_selection( + DietType.FUNGUS, 10.0, (0.1, 1000.0), functional_group_list_instance + ) + expected = { + "fungal_fruiting_bodies": (0.0, 0.0), + } + assert result == expected + + @pytest.mark.parametrize( "diet_flag, expected", [ @@ -211,12 +229,22 @@ def test_carnivore_prey_group_selection(functional_group_list_instance): # Herbivory + scavenging ( DietType.HERBIVORE | DietType.CARCASSES, - {"plants": (0.0, 0.0), "litter": (0.0, 0.0), "carcasses": (0.0, 0.0)}, + { + "plants": (0.0, 0.0), + "litter": (0.0, 0.0), + "carcasses": (0.0, 0.0), + "fungal_fruiting_bodies": (0.0, 0.0), + }, ), # Herbivory + waste ( DietType.HERBIVORE | DietType.WASTE, - {"plants": (0.0, 0.0), "litter": (0.0, 0.0), "excrement": (0.0, 0.0)}, + { + "plants": (0.0, 0.0), + "litter": (0.0, 0.0), + "excrement": (0.0, 0.0), + "fungal_fruiting_bodies": (0.0, 0.0), + }, ), # Detritivory only ( diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index 48fcb3af3..9d661aa23 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -177,6 +177,8 @@ def prey_group_selection( result["excrement"] = (0.0, 0.0) if diet_type & DietType.DETRITUS: result["litter"] = (0.0, 0.0) + if diet_type & DietType.FUNGUS: + result["fungal_fruiting_bodies"] = (0.0, 0.0) if not result: raise ValueError(f"No prey groups matched for diet type: {diet_type}") From 2f9e30fb534808d7cb97306816c28422ebde8a25 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 22 Aug 2025 14:48:34 +0100 Subject: [PATCH 9/9] Revised draft of plant resource class. --- .../models/animal/plant_resources_2.py | 81 ------ .../models/animal/plant_resources_3.py | 230 ++++++++++++------ 2 files changed, 149 insertions(+), 162 deletions(-) delete mode 100644 virtual_ecosystem/models/animal/plant_resources_2.py diff --git a/virtual_ecosystem/models/animal/plant_resources_2.py b/virtual_ecosystem/models/animal/plant_resources_2.py deleted file mode 100644 index f50d698d4..000000000 --- a/virtual_ecosystem/models/animal/plant_resources_2.py +++ /dev/null @@ -1,81 +0,0 @@ -"""The ''plant_resources'' classes provides toy plant module functionality that are -required for setting up and testing the early stages of the animal module. -""" # noqa: D205 - -from virtual_ecosystem.core.data import Data -from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy -from virtual_ecosystem.models.animal.protocols import Consumer - - -class PlantResourcePool: - """Resource pool aggregating plant mass by functional type.""" - - def __init__( - self, - cell_id: int, - functional_group_name: str, - data: "Data", - cell_area: float, - ) -> None: - self.cell_id = cell_id - self.functional_group_name = functional_group_name - self.vertical_occupancy = VerticalOccupancy.CANOPY - - # Optional fields, default to zero if missing - self.leaf_mass = ( - data["layer_leaf_mass"] - .sel(cell_id=cell_id, plant_functional_type=functional_group_name) - .item() - * cell_area - if "layer_leaf_mass" in data - else 0.0 - ) - - self.canopy_n_propagules = ( - data["canopy_n_propagules"] - .sel(cell_id=cell_id, plant_functional_type=functional_group_name) - .item() - if "canopy_n_propagules" in data - else 0.0 - ) - - self.fallen_n_propagules = ( - data["fallen_n_propagules"] - .sel(cell_id=cell_id, plant_functional_type=functional_group_name) - .item() - if "fallen_n_propagules" in data - else 0.0 - ) - - self.subcanopy_veg_mass = ( - data["subcanopy_vegetation_biomass"] - .sel(cell_id=cell_id, plant_functional_type=functional_group_name) - .item() - * cell_area - if "subcanopy_vegetation_biomass" in data - else 0.0 - ) - - self.seedbank_mass = ( - data["subcanopy_seedbank_biomass"] - .sel(cell_id=cell_id, plant_functional_type=functional_group_name) - .item() - * cell_area - if "subcanopy_seedbank_biomass" in data - else 0.0 - ) - - @property - def mass_current(self) -> float: - """Total available plant biomass for this pool.""" - return self.leaf_mass + self.subcanopy_veg_mass - - def get_eaten( - self, - consumed_mass: float, - consumer: "Consumer", - ) -> tuple[dict[str, float], dict[str, float]]: - """Placeholder to satisfy the Resource protocol.""" - raise NotImplementedError( - "Resource-specific get_eaten logic not yet implemented." - ) diff --git a/virtual_ecosystem/models/animal/plant_resources_3.py b/virtual_ecosystem/models/animal/plant_resources_3.py index 9d459e354..27880aa56 100644 --- a/virtual_ecosystem/models/animal/plant_resources_3.py +++ b/virtual_ecosystem/models/animal/plant_resources_3.py @@ -7,101 +7,169 @@ from virtual_ecosystem.models.animal.protocols import Consumer -class AggregatedPlantResource: - """Resource pool aggregating plant mass by resource type.""" +class PlantResource: + """Mutable per-cell plant resource for herbivory integration. + + This class represents a single resource type (e.g., leaves, seedbank) in a + specific grid cell. It initializes its mass from the ``Data`` object using + the provided ``variable_name`` and ``cell_id``. If the backing variable has + a plant functional type (PFT) dimension, per-PFT masses are collected into + ``mass_by_pft`` and summed to produce ``mass_current``. Otherwise a scalar + per-cell value is read. + + Stoichiometry is currently a toy fixed proportion (C=0.7, N=0.2, P=0.1) and + is used to derive ``mass_stoich`` from ``mass_current``. These values are + placeholders until plant-side CNP data are exposed. + """ def __init__( self, + data: Data, cell_id: int, resource_name: str, - data: "Data", - functional_types: list[str], - cell_area: float, - vertical_occupancy: VerticalOccupancy, variable_name: str, + vertical_occupancy: VerticalOccupancy, + *, + pft_dim: str = "plant_functional_type", + cnp_proportions: dict[str, float] | None = None, ) -> None: + """Construct and initialize from ``Data``. + + Reads the mass for ``variable_name`` at ``cell_id``. If the variable has + a PFT axis named ``pft_dim``, per-PFT masses are recorded and summed to + set ``mass_current``. Otherwise a scalar value is read. Toy CNP fractions + are installed if none are provided and ``mass_stoich`` is computed. + + Args: + data: The global ``Data`` object. + cell_id: Grid cell identifier. + resource_name: Human-readable name for this resource. + variable_name: Name of the backing variable in ``Data``. + vertical_occupancy: Vertical position enum for this resource. + pft_dim: Dimension name for PFTs, if present. + cnp_proportions: Optional C, N, P mass fractions. + + Raises: + KeyError: If ``variable_name`` is missing in ``data``. + Exception: Any xarray selection errors will propagate. + """ + # Identity / config self.cell_id = cell_id self.resource_name = resource_name + self.variable_name = variable_name self.vertical_occupancy = vertical_occupancy + self.pft_dim = pft_dim - self.mass_by_fg: dict[str, float] = {} + # Derived stores + self.mass_current: float = 0.0 + self.mass_by_pft: dict[str, float] = {} - for fg in functional_types: - if variable_name not in data: - continue - - try: - per_area = ( - data[variable_name] - .sel(cell_id=cell_id, plant_functional_type=fg) - .item() + # Toy stoichiometry until Plants exposes element data + self.cnp_proportions: dict[str, float] = ( + {"carbon": 0.7, "nitrogen": 0.2, "phosphorus": 0.1} + if cnp_proportions is None + else dict(cnp_proportions) + ) + self.mass_stoich: dict[str, float] = {} + + # Read backing array; let KeyError/xarray errors surface if misconfigured + full_resource_array = data[self.variable_name] + cell_resource_array = full_resource_array.sel(cell_id=self.cell_id) + + # If a PFT axis exists, collect per-PFT and sum; otherwise read scalar + if self.pft_dim in getattr(cell_resource_array, "dims", ()): + pfts = cell_resource_array.coords[self.pft_dim].values # labels per PFT + self.mass_by_pft = { + str(pft_label): float( + cell_resource_array.sel({self.pft_dim: pft_label}).item() ) - self.mass_by_fg[fg] = per_area * cell_area - except KeyError: - continue + for pft_label in pfts + } - @property - def mass_current(self) -> float: - """Return total biomass across all contributing functional groups.""" - return sum(self.mass_by_fg.values()) + self.mass_current = sum(self.mass_by_pft.values()) + else: + self.mass_current = float(cell_resource_array.item()) - def get_eaten( - self, - consumed_mass: float, - consumer: "Consumer", - ) -> tuple[dict[str, float], dict[str, float]]: - """Placeholder to satisfy the Resource protocol.""" - raise NotImplementedError( - "Resource-specific get_eaten logic not yet implemented." - ) + # Initialize derived element masses + self.update_stoichiometric_mass() + + def update_stoichiometric_mass(self) -> None: + """Recompute per-element masses from total mass and CNP fractions. + + Uses ``mass_current`` and ``cnp_proportions`` to populate + ``mass_stoich`` for keys ``carbon``, ``nitrogen``, and ``phosphorus``. + """ + self.mass_stoich = { + element: self.mass_current * proportion + for element, proportion in self.cnp_proportions.items() + } + def set_mass_current(self, new_mass: float) -> None: + """Set aggregate mass and refresh derived stoichiometric masses. -# The following belongs in AnimalModel -""" def populate_aggregated_plant_resources( - data: "Data", - grid: "Grid", - functional_groups: list[str], - cell_area: float, -) -> dict[int, dict[str, AggregatedPlantResource]]: - - - resource_definitions = { - "leaves": { - "variable_name": "layer_leaf_mass", - "vertical_occupancy": VerticalOccupancy.CANOPY, - }, - "seeds": { - "variable_name": "fallen_n_propagules", - "vertical_occupancy": VerticalOccupancy.GROUND, - }, - "subcanopy_veg": { - "variable_name": "subcanopy_vegetation_biomass", - "vertical_occupancy": VerticalOccupancy.GROUND, - }, - "seedbank": { - "variable_name": "subcanopy_seedbank_biomass", - "vertical_occupancy": VerticalOccupancy.SOIL, - }, - } - - resources: dict[int, dict[str, AggregatedPlantResource]] = {} - - for cell_id in grid.cell_id: - cell_resources = {} - - for name, info in resource_definitions.items(): - pool = AggregatedPlantResource( - cell_id=cell_id, - resource_name=name, - data=data, - functional_groups=[fg.name for fg in functional_groups], - cell_area=cell_area, - vertical_occupancy=info["vertical_occupancy"], - variable_name=info["variable_name"], - ) - cell_resources[name] = pool - - resources[cell_id] = cell_resources - - return resources - """ + Args: + new_mass: New aggregate mass (kg). + + Raises: + ValueError: If ``new_mass`` is negative. + """ + if new_mass < 0: + raise ValueError("Mass cannot be negative.") + self.mass_current = new_mass + self.update_stoichiometric_mass() + + def get_eaten( + self, consumed_mass: float, herbivore: "Consumer" + ) -> tuple[dict[str, float], dict[str, float]]: + """Apply herbivory and return herbivore gain and plant litter CNP. + + Mass is removed from the resource up to the requested amount. The mass + is partitioned into: + - Herbivore net gain: ``consumed_mass * mech_eff * conv_eff`` + - Plant litter: ``consumed_mass * (1 - mech_eff)`` + + Stoichiometric masses are computed by multiplying these totals by + ``cnp_proportions``. ``mass_current`` is reduced accordingly and + ``mass_stoich`` is updated. + + Args: + consumed_mass: Intended wet mass to be consumed (kg). + herbivore: Consumer with efficiency parameters. + + Returns: + Tuple of two dicts: + - Herbivore gain CNP ``{"carbon","nitrogen","phosphorus"}`` + - Plant litter CNP ``{"carbon","nitrogen","phosphorus"}`` + + Notes: + If ``consumed_mass <= 0``, both returned dicts contain zeros. + """ + # Handle zero or invalid consumption + if consumed_mass <= 0: + zeros = {e: 0.0 for e in self.cnp_proportions} + return zeros, zeros + + # Cap requested mass to what is available + actual_consumed_mass = min(self.mass_current, consumed_mass) + + # Update plant mass (stoichiometry auto-updates via setter) + self.set_mass_current(self.mass_current - actual_consumed_mass) + + # Partition by mechanical efficiency + mech_eff = herbivore.functional_group.mechanical_efficiency + effective_mass = actual_consumed_mass * mech_eff + excess_mass = actual_consumed_mass * (1.0 - mech_eff) + + # Convert effective mass to body mass by conversion efficiency + conv_eff = herbivore.functional_group.conversion_efficiency + net_mass_gain = effective_mass * conv_eff + + # Map to stoichiometric masses + herbivore_gain_cnp = { + elem: net_mass_gain * prop for elem, prop in self.cnp_proportions.items() + } + plant_litter_cnp = { + elem: excess_mass * prop for elem, prop in self.cnp_proportions.items() + } + + return herbivore_gain_cnp, plant_litter_cnp