From 9b88fe06d41fa6b7b7a968d266a74c45bd3d5509 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 10 Jul 2024 14:31:55 +0100 Subject: [PATCH 01/62] Basic outline of an Animal Territories class. --- .../models/animal/animal_model.py | 21 +++ .../models/animal/animal_territories.py | 122 ++++++++++++++++++ .../models/animal/scaling_functions.py | 3 +- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 virtual_ecosystem/models/animal/animal_territories.py diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 4cfd359e1..ac9e262bf 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -32,6 +32,7 @@ from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity +from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -338,3 +339,23 @@ def calculate_density_for_cohort(self, cohort: AnimalCohort) -> float: population_density = cohort.individuals / community_area return population_density + + def generate_territory(self, size: int, grid_cell: int) -> AnimalTerritory: + """Generate a new territory based on the size and grid cell. + + Args: + size: The side length of the square territory. + grid_cell: The starting grid cell id for the territory. + + Returns: + An AnimalTerritory object. + """ + row, col = divmod(grid_cell, self.grid.cell_nx) + territory_cells = [ + (row + r) * self.grid.cell_nx + (col + c) + for r in range(size) + for c in range(size) + if 0 <= (row + r) < self.grid.cell_ny and 0 <= (col + c) < self.grid.cell_nx + ] + + return AnimalTerritory(territory_cells, self.get_community_by_key) diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py new file mode 100644 index 000000000..8c4f4ca08 --- /dev/null +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -0,0 +1,122 @@ +"""The ''animal'' module provides animal module functionality.""" # noqa: #D205, D415 + +from collections.abc import Callable + +from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort +from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity +from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool +from virtual_ecosystem.models.animal.plant_resources import PlantResources + + +class AnimalTerritory: + """This class defines a territory occupied by an animal cohort. + + The purpose of this class is to function as an intermediary between cohorts + and the plants, pools, and prey of the grid cells that the cohort occupies. It + should have a total area, a list of the specific grid cells within it, and lists of + the plants, pools, and prey. + + The key assumption is that an animal cohort is equally distributed across its + territory for the time-step. + + + + Args: + grid_cells: A list of grid cell ids that make up the territory. + get_animal_community: A function to return an AnimalCommunity for a given grid + cell id. + """ + + def __init__( + self, + grid_cells: list[int], + get_animal_community: Callable[[int], AnimalCommunity], + ) -> None: + # The constructor of the AnimalTerritory class. + self.grid_cells = grid_cells + """A list of grid cells present in the territory.""" + self.get_animal_community = get_animal_community + """A list of animal communities present in the territory.""" + self.territory_prey: list[AnimalCohort] = [] + """A list of animal prey present in the territory.""" + self.territory_plants: list[PlantResources] = [] + """A list of plant resources present in the territory.""" + self.territory_excrement: list[ExcrementPool] = [] + """A list of excrement pools present in the territory.""" + self.territory_carcasses: list[CarcassPool] = [] + """A list of carcass pools present in the territory.""" + + def update_territory(self, consumer_cohort: AnimalCohort) -> None: + """Update territory details at initialization and after migration. + + Args: + consumer_cohort: The AnimalCohort possessing the territory. + + """ + + self.territory_prey = self.get_prey(consumer_cohort) + self.territory_plants = self.get_plant_resources() + self.territory_excrement = self.get_excrement_pools() + self.territory_carcasses = self.get_carcass_pool() + + def get_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: + """Collect suitable prey from all grid cells in the territory. + + TODO: This is probably not the best way to go about this. Maybe alter collect + prey to take the animal community list instead. Prey is probably too dynamic to + store in this way. + + Args: + consumer_cohort: The AnimalCohort for which a prey list is being collected. + + Returns: + A list of AnimalCohorts that can be preyed upon. + """ + prey = [] + for cell_id in self.grid_cells: + community = self.get_animal_community(cell_id) + prey.extend(community.collect_prey(consumer_cohort)) + return prey + + def get_plant_resources(self) -> list[PlantResources]: + """Collect plant resources from all grid cells in the territory. + + TODO: Update internal plant resource generation with a real link to the plant + model. + + Returns: + A list of PlantResources available in the territory. + """ + plant_resources = [] + for cell_id in self.grid_cells: + community = self.get_animal_community(cell_id) + plant_resources.append( + PlantResources( + data=community.data, cell_id=cell_id, constants=community.constants + ) + ) + return plant_resources + + def get_excrement_pools(self) -> list[ExcrementPool]: + """Combine excrement pools from all grid cells in the territory. + + Returns: + A list of ExcrementPools combined from all grid cells. + """ + total_excrement = [] + for cell_id in self.grid_cells: + community = self.get_animal_community(cell_id) + total_excrement.append(community.excrement_pool) + return total_excrement + + def get_carcass_pool(self) -> list[CarcassPool]: + """Combine carcass pools from all grid cells in the territory. + + Returns: + A list of CarcassPools combined from all grid cells. + """ + total_carcass = [] + for cell_id in self.grid_cells: + community = self.get_animal_community(cell_id) + total_carcass.append(community.carcass_pool) + return total_carcass diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index f299eb993..5a86d4d30 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -250,7 +250,7 @@ def k_i_k(alpha_i_k: float, phi_herb_t: float, B_k_t: float, A_cell: float) -> f alpha_i_k: Effective rate at which an individual herbivore searches its environment. phi_herb_t: Fraction of the total plant stock that is available to any one - herbivore cohort + herbivore cohort (default 0.1) B_k_t: Plant resource bool biomass. A_cell: The area of one cell [standard = 1 ha] @@ -370,7 +370,6 @@ def alpha_i_j(alpha_0_pred: float, mass: float, w_bar_i_j: float) -> float: def k_i_j(alpha_i_j: float, N_i_t: float, A_cell: float, theta_i_j: float) -> float: """Potential number of prey items eaten off j by i. - TODO: Finish docstring TODO: double check output needs to be float, might be int TODO: update name From b9636547bbdd1228d70e7dc1bc46f6407fac3ba1 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 10 Jul 2024 16:16:53 +0100 Subject: [PATCH 02/62] Added methods for initialize territory, territory size, updating territory. --- .../models/animal/animal_cohorts.py | 2 + .../models/animal/animal_communities.py | 56 ++++++++++++++++--- .../models/animal/animal_model.py | 23 +------- .../models/animal/animal_territories.py | 30 +++++----- .../models/animal/scaling_functions.py | 30 ++++++++++ 5 files changed, 97 insertions(+), 44 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 2c2f45f20..3066a981e 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -74,6 +74,8 @@ def __init__( self.functional_group.prey_scaling, ) """The identification of useable food resources.""" + self.territory_size = sf.territory_size(self.functional_group.adult_mass) + """The size in hectares of the animal cohorts territory.""" # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 4fd7aee3f..80d1eafab 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -11,13 +11,14 @@ import random from collections.abc import Callable, Iterable from itertools import chain -from math import ceil +from math import ceil, pi, sqrt from numpy import timedelta64 from virtual_ecosystem.core.data import Data from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort +from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory from virtual_ecosystem.models.animal.animal_traits import DevelopmentType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool @@ -41,8 +42,8 @@ class AnimalCommunity: data: The core data object community_key: The integer key of the cell id for this community neighbouring_keys: A list of cell id keys for neighbouring communities - get_destination: A function to return a destination AnimalCommunity for - migration. + get_community_by_key: A function to return a designated AnimalCommunity by + integer key. """ def __init__( @@ -51,7 +52,7 @@ def __init__( data: Data, community_key: int, neighbouring_keys: list[int], - get_destination: Callable[[int], AnimalCommunity], + get_community_by_key: Callable[[int], AnimalCommunity], constants: AnimalConsts = AnimalConsts(), ) -> None: # The constructor of the AnimalCommunity class. @@ -63,8 +64,8 @@ def __init__( """Integer designation of the community in the model grid.""" self.neighbouring_keys = neighbouring_keys """List of integer keys of neighbouring communities.""" - self.get_destination = get_destination - """Callable get_destination from AnimalModel.""" + self.get_community_by_key = get_community_by_key + """Callable get_community_by_key from AnimalModel.""" self.constants = constants """Animal constants.""" @@ -89,6 +90,41 @@ def all_animal_cohorts(self) -> Iterable[AnimalCohort]: """ return chain.from_iterable(self.animal_cohorts.values()) + def initialize_territory( + self, + cohort: AnimalCohort, + centroid_key: int, + territory_size: float, + get_community_by_key: Callable[[int], AnimalCommunity], + ) -> AnimalTerritory: + """This initializes the territory occupied by the cohort. + + Args: + cohort: The animal cohort occupying the territory. + centroid_key: The community key anchoring the territory. + territory_size: The size of the territory in hectares. + get_community_by_key: The method for accessing animal communities by key. + + Returns: An AnimalTerritory object of appropriate size. + """ + # Convert territory size to radius in terms of grid cells + radius = sqrt(territory_size / pi) + + # Convert centroid key to row and column indices + row, col = divmod(centroid_key, self.data.grid.cell_nx) + + territory_cells = [] + + # Generate grid cells within the radius + for r in range(ceil(row - radius), ceil(row + radius) + 1): + for c in range(ceil(col - radius), ceil(col + radius) + 1): + if 0 <= r < self.data.grid.cell_ny and 0 <= c < self.data.grid.cell_nx: + distance = sqrt((r - row) ** 2 + (c - col) ** 2) + if distance <= radius: + territory_cells.append(r * self.data.grid.cell_nx + c) + + return AnimalTerritory(territory_cells, get_community_by_key) + def populate_community(self) -> None: """This function creates an instance of each functional group. @@ -116,6 +152,12 @@ def populate_community(self) -> None: self.constants, ) self.animal_cohorts[functional_group.name].append(cohort) + self.initialize_territory( + cohort, + self.community_key, + cohort.territory_size, + self.get_community_by_key, + ) def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: """Function to move an AnimalCohort between AnimalCommunity objects. @@ -155,7 +197,7 @@ def migrate_community(self) -> None: return destination_key = random.choice(self.neighbouring_keys) - destination = self.get_destination(destination_key) + destination = self.get_community_by_key(destination_key) self.migrate(cohort, destination) def remove_dead_cohort(self, cohort: AnimalCohort) -> None: diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index ac9e262bf..ad916df45 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -32,7 +32,6 @@ from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity -from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -129,7 +128,7 @@ def _initialize_communities(self, functional_groups: list[FunctionalGroup]) -> N data=self.data, community_key=k, neighbouring_keys=list(self.data.grid.neighbours[k]), - get_destination=self.get_community_by_key, + get_community_by_key=self.get_community_by_key, constants=self.model_constants, ) for k in self.data.grid.cell_id @@ -339,23 +338,3 @@ def calculate_density_for_cohort(self, cohort: AnimalCohort) -> float: population_density = cohort.individuals / community_area return population_density - - def generate_territory(self, size: int, grid_cell: int) -> AnimalTerritory: - """Generate a new territory based on the size and grid cell. - - Args: - size: The side length of the square territory. - grid_cell: The starting grid cell id for the territory. - - Returns: - An AnimalTerritory object. - """ - row, col = divmod(grid_cell, self.grid.cell_nx) - territory_cells = [ - (row + r) * self.grid.cell_nx + (col + c) - for r in range(size) - for c in range(size) - if 0 <= (row + r) < self.grid.cell_ny and 0 <= (col + c) < self.grid.cell_nx - ] - - return AnimalTerritory(territory_cells, self.get_community_by_key) diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 8c4f4ca08..a032f0c1a 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -22,20 +22,20 @@ class AnimalTerritory: Args: - grid_cells: A list of grid cell ids that make up the territory. - get_animal_community: A function to return an AnimalCommunity for a given grid - cell id. + grid_cell_keys: A list of grid cell ids that make up the territory. + get_community_by_key: A function to return an AnimalCommunity for a given + integer key. """ def __init__( self, - grid_cells: list[int], - get_animal_community: Callable[[int], AnimalCommunity], + grid_cell_keys: list[int], + get_community_by_key: Callable[[int], AnimalCommunity], ) -> None: # The constructor of the AnimalTerritory class. - self.grid_cells = grid_cells + self.grid_cell_keys = grid_cell_keys """A list of grid cells present in the territory.""" - self.get_animal_community = get_animal_community + self.get_community_by_key = get_community_by_key """A list of animal communities present in the territory.""" self.territory_prey: list[AnimalCohort] = [] """A list of animal prey present in the territory.""" @@ -73,8 +73,8 @@ def get_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: A list of AnimalCohorts that can be preyed upon. """ prey = [] - for cell_id in self.grid_cells: - community = self.get_animal_community(cell_id) + for cell_id in self.grid_cell_keys: + community = self.get_community_by_key(cell_id) prey.extend(community.collect_prey(consumer_cohort)) return prey @@ -88,8 +88,8 @@ def get_plant_resources(self) -> list[PlantResources]: A list of PlantResources available in the territory. """ plant_resources = [] - for cell_id in self.grid_cells: - community = self.get_animal_community(cell_id) + for cell_id in self.grid_cell_keys: + community = self.get_community_by_key(cell_id) plant_resources.append( PlantResources( data=community.data, cell_id=cell_id, constants=community.constants @@ -104,8 +104,8 @@ def get_excrement_pools(self) -> list[ExcrementPool]: A list of ExcrementPools combined from all grid cells. """ total_excrement = [] - for cell_id in self.grid_cells: - community = self.get_animal_community(cell_id) + for cell_id in self.grid_cell_keys: + community = self.get_community_by_key(cell_id) total_excrement.append(community.excrement_pool) return total_excrement @@ -116,7 +116,7 @@ def get_carcass_pool(self) -> list[CarcassPool]: A list of CarcassPools combined from all grid cells. """ total_carcass = [] - for cell_id in self.grid_cells: - community = self.get_animal_community(cell_id) + for cell_id in self.grid_cell_keys: + community = self.get_community_by_key(cell_id) total_carcass.append(community.carcass_pool) return total_carcass diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index 5a86d4d30..b486fec49 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -438,3 +438,33 @@ def juvenile_dispersal_speed( """ return V_disp * (current_mass / M_disp_ref) ** o_disp + + +def territory_size(mass: float) -> float: + """This function provides allometric scaling for territory size. + + TODO: Replace this toy scaling with a real allometry + + Args: + mass: The mass of the animal cohort + + Returns: + The size of the cohort's territory in hectares + """ + + if mass < 10.0: + territory = 1.0 + elif 10.0 <= mass < 25.0: + territory = 2.0 + elif 25.0 <= mass < 50.0: + territory = 5.0 + elif 50.0 <= mass < 100.0: + territory = 10.0 + elif 100.0 <= mass < 200.0: + territory = 15.0 + elif 200.0 <= mass < 500.0: + territory = 20.0 + else: + territory = 30.0 + + return territory From e98816173f86b4c44dd0d70e8e993636c8b735ae Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 11 Jul 2024 13:33:34 +0100 Subject: [PATCH 03/62] Revised initialize_territory to use breadth-first search. --- .../models/animal/animal_communities.py | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 80d1eafab..62a08a261 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -11,7 +11,7 @@ import random from collections.abc import Callable, Iterable from itertools import chain -from math import ceil, pi, sqrt +from math import ceil from numpy import timedelta64 @@ -94,11 +94,17 @@ def initialize_territory( self, cohort: AnimalCohort, centroid_key: int, - territory_size: float, get_community_by_key: Callable[[int], AnimalCommunity], ) -> AnimalTerritory: """This initializes the territory occupied by the cohort. + Breadth-first search (BFS) does some slightly weird stuff on a grid of squares + but behaves properly on a graph. As we are talking about moving to a graph + anyway, I can leave it like this and make adjustments for diagonals if we decide + to stay with squares/cells. + + TODO: Revise for diagonals if we stay on grid squares/cells. + Args: cohort: The animal cohort occupying the territory. centroid_key: The community key anchoring the territory. @@ -106,22 +112,41 @@ def initialize_territory( get_community_by_key: The method for accessing animal communities by key. Returns: An AnimalTerritory object of appropriate size. + """ - # Convert territory size to radius in terms of grid cells - radius = sqrt(territory_size / pi) + # Each grid cell is 1 hectare, territory size in grids is the same as hectares + target_cells = cohort.territory_size # Convert centroid key to row and column indices row, col = divmod(centroid_key, self.data.grid.cell_nx) territory_cells = [] - # Generate grid cells within the radius - for r in range(ceil(row - radius), ceil(row + radius) + 1): - for c in range(ceil(col - radius), ceil(col + radius) + 1): - if 0 <= r < self.data.grid.cell_ny and 0 <= c < self.data.grid.cell_nx: - distance = sqrt((r - row) ** 2 + (c - col) ** 2) - if distance <= radius: - territory_cells.append(r * self.data.grid.cell_nx + c) + # Start with the center cell + territory_cells.append(centroid_key) + + # Use a BFS-like approach to add cells around the center + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] # Up, Down, Left, Right + visited = set(territory_cells) + + queue = [(row, col)] + + while queue and len(territory_cells) < target_cells: + r, c = queue.pop(0) + + for dr, dc in directions: + nr, nc = r + dr, c + dc + if ( + 0 <= nr < self.data.grid.cell_ny + and 0 <= nc < self.data.grid.cell_nx + ): + new_cell = nr * self.data.grid.cell_nx + nc + if new_cell not in visited: + visited.add(new_cell) + territory_cells.append(new_cell) + queue.append((nr, nc)) + if len(territory_cells) >= target_cells: + break return AnimalTerritory(territory_cells, get_community_by_key) @@ -155,7 +180,6 @@ def populate_community(self) -> None: self.initialize_territory( cohort, self.community_key, - cohort.territory_size, self.get_community_by_key, ) From 11a83267a9eac06e36a1db481ccba2f07f3938d5 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 11 Jul 2024 14:01:41 +0100 Subject: [PATCH 04/62] Added a Territory protocol to avoid circular reference. --- virtual_ecosystem/models/animal/animal_cohorts.py | 9 ++++++++- virtual_ecosystem/models/animal/animal_communities.py | 8 +++++++- virtual_ecosystem/models/animal/protocols.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 3066a981e..828e4ea4c 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -19,7 +19,12 @@ from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup -from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool, Resource +from virtual_ecosystem.models.animal.protocols import ( + Consumer, + DecayPool, + Resource, + Territory, +) class AnimalCohort: @@ -76,6 +81,8 @@ def __init__( """The identification of useable food resources.""" self.territory_size = sf.territory_size(self.functional_group.adult_mass) """The size in hectares of the animal cohorts territory.""" + self.territory: Territory | None = None + """The AnimalTerritory object associated with the cohort.""" # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 62a08a261..1872a30cc 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -169,6 +169,7 @@ def populate_community(self) -> None: functional_group.adult_mass, functional_group.damuths_law_terms ) + # create a cohort of the functional group cohort = AnimalCohort( functional_group, functional_group.adult_mass, @@ -176,12 +177,17 @@ def populate_community(self) -> None: individuals, self.constants, ) + # add the cohort to the community self.animal_cohorts[functional_group.name].append(cohort) - self.initialize_territory( + + # generate a territory for the cohort + territory = self.initialize_territory( cohort, self.community_key, self.get_community_by_key, ) + # add the territory to the cohort's attributes + cohort.territory = territory def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: """Function to move an AnimalCohort between AnimalCommunity objects. diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index deaca524f..a2077201f 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -39,3 +39,14 @@ def get_eaten( ) -> float: """The get_eaten method defines a resource.""" ... + + +class Territory(Protocol): + """This is the protocol for defining territories. + + Currently, this is an intermediary to prevent circular reference between territories + and cohorts. + + """ + + grid_cell_keys: list[int] From 0286b62e8864d309244cba89724c25aa10759e60 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 11 Jul 2024 14:10:52 +0100 Subject: [PATCH 05/62] Added calls to initialize_territory to birth, migration, and metamorphose. --- .../models/animal/animal_communities.py | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 1872a30cc..aff093043 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -95,7 +95,7 @@ def initialize_territory( cohort: AnimalCohort, centroid_key: int, get_community_by_key: Callable[[int], AnimalCommunity], - ) -> AnimalTerritory: + ) -> None: """This initializes the territory occupied by the cohort. Breadth-first search (BFS) does some slightly weird stuff on a grid of squares @@ -104,6 +104,8 @@ def initialize_territory( to stay with squares/cells. TODO: Revise for diagonals if we stay on grid squares/cells. + TODO: might be able to save time with an ifelse for small territories + TODO: maybe BFS should be an independent function? Args: cohort: The animal cohort occupying the territory. @@ -148,7 +150,10 @@ def initialize_territory( if len(territory_cells) >= target_cells: break - return AnimalTerritory(territory_cells, get_community_by_key) + # generate the territory + territory = AnimalTerritory(territory_cells, get_community_by_key) + # add the territory to the cohort's attributes + cohort.territory = territory def populate_community(self) -> None: """This function creates an instance of each functional group. @@ -181,13 +186,11 @@ def populate_community(self) -> None: self.animal_cohorts[functional_group.name].append(cohort) # generate a territory for the cohort - territory = self.initialize_territory( + self.initialize_territory( cohort, self.community_key, self.get_community_by_key, ) - # add the territory to the cohort's attributes - cohort.territory = territory def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: """Function to move an AnimalCohort between AnimalCommunity objects. @@ -207,6 +210,13 @@ def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: self.animal_cohorts[migrant.name].remove(migrant) destination.animal_cohorts[migrant.name].append(migrant) + # regenerate a territory for the cohort at the destination community + self.initialize_territory( + migrant, + destination.community_key, + destination.get_community_by_key, + ) + def migrate_community(self) -> None: """This handles migrating all cohorts in a community. @@ -299,6 +309,13 @@ def birth(self, parent_cohort: AnimalCohort) -> None: self.constants, ) + # generate a territory for the offspring cohort + self.initialize_territory( + offspring_cohort, + self.community_key, + self.get_community_by_key, + ) + # add a new cohort of the parental type to the community self.animal_cohorts[parent_cohort.name].append(offspring_cohort) @@ -483,6 +500,13 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: self.constants, ) + # generate a territory for the adult cohort + self.initialize_territory( + adult_cohort, + self.community_key, + self.get_community_by_key, + ) + # add a new cohort of the parental type to the community self.animal_cohorts[adult_cohort.name].append(adult_cohort) From 0e3d2a31d2b0b41ba7e58e91c0a637c9a43ca693 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 11 Jul 2024 15:31:50 +0100 Subject: [PATCH 06/62] Changed initialize_territory to use dynamic importing. --- .../models/animal/animal_communities.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index aff093043..f63e3a779 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib import random from collections.abc import Callable, Iterable from itertools import chain @@ -18,7 +19,6 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory from virtual_ecosystem.models.animal.animal_traits import DevelopmentType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool @@ -116,6 +116,11 @@ def initialize_territory( Returns: An AnimalTerritory object of appropriate size. """ + + AnimalTerritory = importlib.import_module( + "animal_territories" + ).AnimalTerritories + # Each grid cell is 1 hectare, territory size in grids is the same as hectares target_cells = cohort.territory_size @@ -224,6 +229,8 @@ def migrate_community(self) -> None: 1) The cohort is starving and needs to move for a chance at resource access 2) An initial migration event immediately after birth. + TODO: MGO - migrate distance mod for larger territories? + """ for cohort in self.all_animal_cohorts: migrate = cohort.is_below_mass_threshold( @@ -271,6 +278,7 @@ def birth(self, parent_cohort: AnimalCohort) -> None: TODO: Check whether Madingley discards excess reproductive mass. TODO: Rework birth mass for indirect developers. + TODO: MGO - cohorts are born at centroid Args: parent_cohort: The AnimalCohort instance which is producing a new cohort. @@ -343,6 +351,9 @@ def forage_community(self) -> None: include functions for handling scavenging and soil consumption behaviors. Cohorts with no remaining individuals post-foraging are marked for death. + + TODO: MGO - forage over territory instead of community + """ # Generate the plant resources for foraging. plant_community: PlantResources = PlantResources( @@ -381,6 +392,8 @@ def collect_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: Returns: A list of AnimalCohorts that can be preyed upon. + TODO: MGO - collect prey over territory + """ prey: list = [] for ( @@ -418,6 +431,7 @@ def metabolize_community(self, temperature: float, dt: timedelta64) -> None: spatially explicit with multi-grid occupancy. TODO: Rework with stoichiometry + TODO: MGO - rework excretion for territories Args: temperature: Current air temperature (K). @@ -476,6 +490,7 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: TODO: Build in a relationship between larval_cohort mass and adult cohort mass. TODO: Is adult_mass the correct mass threshold? TODO: If the time step drops below a month, this needs an intermediary stage. + TODO: MGO - metamorphose at centroid? Args: larval_cohort: The cohort in its larval stage to be transformed. From 58a32a5ede4a1444fc441f5a0aa52a8c470bf9fa Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 11 Jul 2024 16:41:04 +0100 Subject: [PATCH 07/62] Added testing for AnimalTerritory. --- tests/models/animals/conftest.py | 2 +- .../models/animals/test_animal_territories.py | 93 +++++++++++++++++++ .../models/animal/animal_communities.py | 4 +- 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/models/animals/test_animal_territories.py diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 1219be66f..9edf656a6 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -185,7 +185,7 @@ def animal_community_instance( data=animal_data_for_community_instance, community_key=4, neighbouring_keys=[1, 3, 5, 7], - get_destination=animal_model_instance.get_community_by_key, + get_community_by_key=animal_model_instance.get_community_by_key, constants=constants_instance, ) diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py new file mode 100644 index 000000000..0eb3ade25 --- /dev/null +++ b/tests/models/animals/test_animal_territories.py @@ -0,0 +1,93 @@ +"""Test module for animal_territories.py.""" + +import pytest + + +class TestAnimalTerritories: + """For testing the AnimalTerritories class.""" + + @pytest.fixture + def get_community_by_key(self, animal_community_instance): + """Fixture for get_community_by_key.""" + + def _get_community_by_key(key): + return animal_community_instance + + return _get_community_by_key + + @pytest.fixture + def animal_territory_instance(self, get_community_by_key): + """Fixture for animal territories.""" + from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory + + return AnimalTerritory( + grid_cell_keys=[1, 2, 3], get_community_by_key=get_community_by_key + ) + + def test_update_territory( + self, mocker, animal_territory_instance, herbivore_cohort_instance + ): + """Test for update_territory method.""" + mock_get_prey = mocker.patch.object( + animal_territory_instance, "get_prey", return_value=[] + ) + mock_get_plant_resources = mocker.patch.object( + animal_territory_instance, "get_plant_resources", return_value=[] + ) + mock_get_excrement_pools = mocker.patch.object( + animal_territory_instance, "get_excrement_pools", return_value=[] + ) + mock_get_carcass_pool = mocker.patch.object( + animal_territory_instance, "get_carcass_pool", return_value=[] + ) + + animal_territory_instance.update_territory(herbivore_cohort_instance) + + mock_get_prey.assert_called_once_with(herbivore_cohort_instance) + mock_get_plant_resources.assert_called_once() + mock_get_excrement_pools.assert_called_once() + mock_get_carcass_pool.assert_called_once() + + def test_get_prey( + self, + mocker, + animal_territory_instance, + herbivore_cohort_instance, + animal_community_instance, + ): + """Test for get_prey method.""" + mock_collect_prey = mocker.patch.object( + animal_community_instance, "collect_prey", return_value=[] + ) + + prey = animal_territory_instance.get_prey(herbivore_cohort_instance) + assert prey == [] + for cell_id in animal_territory_instance.grid_cell_keys: + mock_collect_prey.assert_any_call(herbivore_cohort_instance) + + def test_get_plant_resources(self, animal_territory_instance): + """Test for get_plant_resources method.""" + from virtual_ecosystem.models.animal.plant_resources import PlantResources + + plant_resources = animal_territory_instance.get_plant_resources() + assert len(plant_resources) == len(animal_territory_instance.grid_cell_keys) + for plant in plant_resources: + assert isinstance(plant, PlantResources) + + def test_get_excrement_pools(self, animal_territory_instance): + """Test for get_excrement pools method.""" + from virtual_ecosystem.models.animal.decay import ExcrementPool + + excrement_pools = animal_territory_instance.get_excrement_pools() + assert len(excrement_pools) == len(animal_territory_instance.grid_cell_keys) + for excrement in excrement_pools: + assert isinstance(excrement, ExcrementPool) + + def test_get_carcass_pool(self, animal_territory_instance): + """Test for get carcass pool method.""" + from virtual_ecosystem.models.animal.decay import CarcassPool + + carcass_pools = animal_territory_instance.get_carcass_pool() + assert len(carcass_pools) == len(animal_territory_instance.grid_cell_keys) + for carcass in carcass_pools: + assert isinstance(carcass, CarcassPool) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index f63e3a779..2440a8fc8 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -118,8 +118,8 @@ def initialize_territory( """ AnimalTerritory = importlib.import_module( - "animal_territories" - ).AnimalTerritories + "virtual_ecosystem.models.animal.animal_territories" + ).AnimalTerritory # Each grid cell is 1 hectare, territory size in grids is the same as hectares target_cells = cohort.territory_size From 67e0997d93e74c1307658d0730ee0ad9d5a95d76 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 12 Jul 2024 12:48:20 +0100 Subject: [PATCH 08/62] Refactored initialize_territory so that bfs_territory is a stand alone function. --- .../models/animal/animal_communities.py | 61 ++++--------------- .../models/animal/scaling_functions.py | 56 +++++++++++++++++ 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 2440a8fc8..25bae02af 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -27,7 +27,7 @@ get_functional_group_by_name, ) from virtual_ecosystem.models.animal.plant_resources import PlantResources -from virtual_ecosystem.models.animal.scaling_functions import damuths_law +from virtual_ecosystem.models.animal.scaling_functions import bfs_territory, damuths_law class AnimalCommunity: @@ -98,66 +98,31 @@ def initialize_territory( ) -> None: """This initializes the territory occupied by the cohort. - Breadth-first search (BFS) does some slightly weird stuff on a grid of squares - but behaves properly on a graph. As we are talking about moving to a graph - anyway, I can leave it like this and make adjustments for diagonals if we decide - to stay with squares/cells. - - TODO: Revise for diagonals if we stay on grid squares/cells. - TODO: might be able to save time with an ifelse for small territories - TODO: maybe BFS should be an independent function? + TODO: update the territory size to cell number conversion using grid size Args: cohort: The animal cohort occupying the territory. centroid_key: The community key anchoring the territory. - territory_size: The size of the territory in hectares. get_community_by_key: The method for accessing animal communities by key. - - Returns: An AnimalTerritory object of appropriate size. - """ - AnimalTerritory = importlib.import_module( "virtual_ecosystem.models.animal.animal_territories" ).AnimalTerritory # Each grid cell is 1 hectare, territory size in grids is the same as hectares - target_cells = cohort.territory_size - - # Convert centroid key to row and column indices - row, col = divmod(centroid_key, self.data.grid.cell_nx) - - territory_cells = [] - - # Start with the center cell - territory_cells.append(centroid_key) - - # Use a BFS-like approach to add cells around the center - directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] # Up, Down, Left, Right - visited = set(territory_cells) - - queue = [(row, col)] - - while queue and len(territory_cells) < target_cells: - r, c = queue.pop(0) + target_cell_number = int(cohort.territory_size) + + # Perform BFS to determine the territory cells + territory_cells = bfs_territory( + centroid_key, + target_cell_number, + self.data.grid.cell_nx, + self.data.grid.cell_ny, + ) - for dr, dc in directions: - nr, nc = r + dr, c + dc - if ( - 0 <= nr < self.data.grid.cell_ny - and 0 <= nc < self.data.grid.cell_nx - ): - new_cell = nr * self.data.grid.cell_nx + nc - if new_cell not in visited: - visited.add(new_cell) - territory_cells.append(new_cell) - queue.append((nr, nc)) - if len(territory_cells) >= target_cells: - break - - # generate the territory + # Generate the territory territory = AnimalTerritory(territory_cells, get_community_by_key) - # add the territory to the cohort's attributes + # Add the territory to the cohort's attributes cohort.territory = territory def populate_community(self) -> None: diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index b486fec49..49cf28dc0 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -444,6 +444,7 @@ def territory_size(mass: float) -> float: """This function provides allometric scaling for territory size. TODO: Replace this toy scaling with a real allometry + TODO: decide if this allometry will be based on current mass or adult mass Args: mass: The mass of the animal cohort @@ -468,3 +469,58 @@ def territory_size(mass: float) -> float: territory = 30.0 return territory + + +def bfs_territory( + centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int +) -> list[int]: + """Performs breadth-first search (BFS) to generate a list of territory cells. + + BFS does some slightly weird stuff on a grid of squares but behaves properly on a + graph. As we are talking about moving to a graph anyway, I can leave it like this + and make adjustments for diagonals if we decide to stay with squares/cells. + + TODO: Revise for diagonals if we stay on grid squares/cells. + TODO: might be able to save time with an ifelse for small territories + + Args: + centroid_key: The community key anchoring the territory. + target_cell_number: The number of grid cells in the territory. + cell_nx: Number of cells along the x-axis. + cell_ny: Number of cells along the y-axis. + + Returns: + A list of grid cell keys representing the territory. + """ + + # Convert centroid key to row and column indices + row, col = divmod(centroid_key, cell_nx) + # Initialize the territory cells list with the centroid key + territory_cells = [centroid_key] + # Define the possible directions for BFS traversal: Up, Down, Left, Right + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + # Set to keep track of visited cells to avoid revisiting + visited = set(territory_cells) + # Queue for BFS, initialized with the starting position (row, col) + queue = [(row, col)] + # Perform BFS until the queue is empty or we reach the target number of cells + while queue and len(territory_cells) < target_cell_number: + # Dequeue the next cell to process + r, c = queue.pop(0) + # Explore all neighboring cells in the defined directions + for dr, dc in directions: + nr, nc = r + dr, c + dc + # Check if the new cell is within grid bounds + if 0 <= nr < cell_ny and 0 <= nc < cell_nx: + new_cell = nr * cell_nx + nc + # If the cell hasn't been visited, mark it as visited and add to the + # territory + if new_cell not in visited: + visited.add(new_cell) + territory_cells.append(new_cell) + queue.append((nr, nc)) + # If we have reached the target number of cells, exit the loop + if len(territory_cells) >= target_cell_number: + break + + return territory_cells From 195417b33c244db479f7a8f6a7e704deecd70049 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 12 Jul 2024 15:30:00 +0100 Subject: [PATCH 09/62] Added test for bfs_territory. --- .../models/animals/test_animal_territories.py | 25 ++++++++ .../models/animal/animal_cohorts.py | 7 +++ .../models/animal/animal_communities.py | 6 +- .../models/animal/animal_territories.py | 61 +++++++++++++++++++ .../models/animal/scaling_functions.py | 55 ----------------- 5 files changed, 98 insertions(+), 56 deletions(-) diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py index 0eb3ade25..d12a190bd 100644 --- a/tests/models/animals/test_animal_territories.py +++ b/tests/models/animals/test_animal_territories.py @@ -91,3 +91,28 @@ def test_get_carcass_pool(self, animal_territory_instance): assert len(carcass_pools) == len(animal_territory_instance.grid_cell_keys) for carcass in carcass_pools: assert isinstance(carcass, CarcassPool) + + +@pytest.mark.parametrize( + "centroid_key, target_cell_number, cell_nx, cell_ny, expected", + [ + (0, 1, 3, 3, {0}), # Single cell territory + (4, 5, 3, 3, {4, 3, 5, 1, 7}), # Small territory in the center + (0, 4, 3, 3, {0, 1, 3, 6}), # Territory starting at the corner + (8, 4, 3, 3, {8, 2, 5, 7}), # Territory starting at another corner + (4, 9, 3, 3, {4, 3, 5, 1, 7, 0, 2, 6, 8}), # Full grid territory + ], + ids=[ + "single_cell", + "small_center", + "corner_start", + "another_corner", + "full_grid", + ], +) +def test_bfs_territory(centroid_key, target_cell_number, cell_nx, cell_ny, expected): + """Test bfs_territory with various parameters.""" + from virtual_ecosystem.models.animal.animal_territories import bfs_territory + + result = set(bfs_territory(centroid_key, target_cell_number, cell_nx, cell_ny)) + assert result == expected, f"Expected {expected}, but got {result}" diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 828e4ea4c..8f6ed10d0 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -184,6 +184,7 @@ def defecate( TODO: Rework after update litter pools for mass TODO: update for current conversion efficiency TODO: Update with stoichiometry + TODO: MGO - rework for territories (if need be) Args: excrement_pool: The local ExcrementSoil pool in which waste is deposited. @@ -235,6 +236,7 @@ def die_individual(self, number_dead: int, carcass_pool: CarcassPool) -> None: fixed once the litter pools are updated for mass. TODO: Rework after update litter pools for mass + TODO: MGO - rework for territories Args: number_dead: The number of individuals by which to decrease the population @@ -279,6 +281,9 @@ def get_eaten( It finds the smallest whole number of prey required to satisfy the predators mass demands and caps at then caps it at the available population. + TODO: MGO - rework for territories + + Args: potential_consumed_mass: The mass intended to be consumed by the predator. predator: The predator consuming the cohort. @@ -362,6 +367,7 @@ def calculate_total_handling_time_for_herbivory( the total handling time required by the cohort. TODO: give A_cell a grid size reference. + TODO: MGO - rework for territories Args: plant_list: A sequence of plant resources available for consumption by the @@ -468,6 +474,7 @@ def calculate_potential_prey_consumed( """Calculate the potential number of prey consumed. TODO: give A_cell a grid size reference + TODO: MGO - rework for territories Args: alpha: the predation search rate diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 25bae02af..50e30849c 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -27,7 +27,7 @@ get_functional_group_by_name, ) from virtual_ecosystem.models.animal.plant_resources import PlantResources -from virtual_ecosystem.models.animal.scaling_functions import bfs_territory, damuths_law +from virtual_ecosystem.models.animal.scaling_functions import damuths_law class AnimalCommunity: @@ -109,6 +109,10 @@ def initialize_territory( "virtual_ecosystem.models.animal.animal_territories" ).AnimalTerritory + bfs_territory = importlib.import_module( + "virtual_ecosystem.models.animal.animal_territories" + ).bfs_territory + # Each grid cell is 1 hectare, territory size in grids is the same as hectares target_cell_number = int(cohort.territory_size) diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index a032f0c1a..8c55f60e6 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -120,3 +120,64 @@ def get_carcass_pool(self) -> list[CarcassPool]: community = self.get_community_by_key(cell_id) total_carcass.append(community.carcass_pool) return total_carcass + + +def bfs_territory( + centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int +) -> list[int]: + """Performs breadth-first search (BFS) to generate a list of territory cells. + + BFS does some slightly weird stuff on a grid of squares but behaves properly on a + graph. As we are talking about moving to a graph anyway, I can leave it like this + and make adjustments for diagonals if we decide to stay with squares/cells. + + TODO: Revise for diagonals if we stay on grid squares/cells. + TODO: might be able to save time with an ifelse for small territories + + Args: + centroid_key: The community key anchoring the territory. + target_cell_number: The number of grid cells in the territory. + cell_nx: Number of cells along the x-axis. + cell_ny: Number of cells along the y-axis. + + Returns: + A list of grid cell keys representing the territory. + """ + + # Convert centroid key to row and column indices + row, col = divmod(centroid_key, cell_nx) + + # Initialize the territory cells list with the centroid key + territory_cells = [centroid_key] + + # Define the possible directions for BFS traversal: Up, Down, Left, Right + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + # Set to keep track of visited cells to avoid revisiting + visited = set(territory_cells) + + # Queue for BFS, initialized with the starting position (row, col) + queue = [(row, col)] + + # Perform BFS until the queue is empty or we reach the target number of cells + while queue and len(territory_cells) < target_cell_number: + # Dequeue the next cell to process + r, c = queue.pop(0) + + # Explore all neighboring cells in the defined directions + for dr, dc in directions: + nr, nc = r + dr, c + dc + # Check if the new cell is within grid bounds + if 0 <= nr < cell_ny and 0 <= nc < cell_nx: + new_cell = nr * cell_nx + nc + # If the cell hasn't been visited, mark it as visited and add to the + # territory + if new_cell not in visited: + visited.add(new_cell) + territory_cells.append(new_cell) + queue.append((nr, nc)) + # If we have reached the target number of cells, exit the loop + if len(territory_cells) >= target_cell_number: + break + + return territory_cells diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index 49cf28dc0..53fbb522e 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -469,58 +469,3 @@ def territory_size(mass: float) -> float: territory = 30.0 return territory - - -def bfs_territory( - centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int -) -> list[int]: - """Performs breadth-first search (BFS) to generate a list of territory cells. - - BFS does some slightly weird stuff on a grid of squares but behaves properly on a - graph. As we are talking about moving to a graph anyway, I can leave it like this - and make adjustments for diagonals if we decide to stay with squares/cells. - - TODO: Revise for diagonals if we stay on grid squares/cells. - TODO: might be able to save time with an ifelse for small territories - - Args: - centroid_key: The community key anchoring the territory. - target_cell_number: The number of grid cells in the territory. - cell_nx: Number of cells along the x-axis. - cell_ny: Number of cells along the y-axis. - - Returns: - A list of grid cell keys representing the territory. - """ - - # Convert centroid key to row and column indices - row, col = divmod(centroid_key, cell_nx) - # Initialize the territory cells list with the centroid key - territory_cells = [centroid_key] - # Define the possible directions for BFS traversal: Up, Down, Left, Right - directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] - # Set to keep track of visited cells to avoid revisiting - visited = set(territory_cells) - # Queue for BFS, initialized with the starting position (row, col) - queue = [(row, col)] - # Perform BFS until the queue is empty or we reach the target number of cells - while queue and len(territory_cells) < target_cell_number: - # Dequeue the next cell to process - r, c = queue.pop(0) - # Explore all neighboring cells in the defined directions - for dr, dc in directions: - nr, nc = r + dr, c + dc - # Check if the new cell is within grid bounds - if 0 <= nr < cell_ny and 0 <= nc < cell_nx: - new_cell = nr * cell_nx + nc - # If the cell hasn't been visited, mark it as visited and add to the - # territory - if new_cell not in visited: - visited.add(new_cell) - territory_cells.append(new_cell) - queue.append((nr, nc)) - # If we have reached the target number of cells, exit the loop - if len(territory_cells) >= target_cell_number: - break - - return territory_cells From 0aa128f08a2420d6e464a0d4b9a8d2571ef4d5d4 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 12 Jul 2024 16:17:17 +0100 Subject: [PATCH 10/62] Added test for initialize_territory. --- .../models/animals/test_animal_communities.py | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py index 61058cb3c..29f17259b 100644 --- a/tests/models/animals/test_animal_communities.py +++ b/tests/models/animals/test_animal_communities.py @@ -21,7 +21,7 @@ def animal_community_destination_instance( data=animal_data_for_community_instance, community_key=4, neighbouring_keys=[1, 3, 5, 7], - get_destination=animal_model_instance.get_community_by_key, + get_community_by_key=animal_model_instance.get_community_by_key, constants=constants_instance, ) @@ -53,6 +53,22 @@ def animal_cohort_instance(functional_group_instance, constants_instance): ) +@pytest.fixture +def mock_animal_territory(mocker): + """Mock fixture for animal territory.""" + return mocker.patch( + "virtual_ecosystem.models.animal.animal_territories.AnimalTerritory" + ) + + +@pytest.fixture +def mock_bfs_territory(mocker): + """Mock fixture for the bfs_territory function.""" + return mocker.patch( + "virtual_ecosystem.models.animal.animal_territories.bfs_territory" + ) + + class TestAnimalCommunity: """Test AnimalCommunity class.""" @@ -178,10 +194,10 @@ def test_migrate_community( cohort.age = age cohort.mass_current = cohort.functional_group.adult_mass * mass_ratio - # Mock the get_destination callable to return a specific community. + # Mock the get_community_by_key callable to return a specific community. mocker.patch.object( animal_community_instance, - "get_destination", + "get_community_by_key", return_value=animal_community_destination_instance, ) @@ -674,3 +690,50 @@ def test_metamorphose_community( assert new_caterpillar_count == expected_caterpillar_count assert new_butterfly_count == expected_butterfly_count assert caterpillar_cohort.is_alive == expected_is_alive + + def test_initialize_territory( + self, + mocker, + animal_community_instance, + mock_animal_territory, + mock_bfs_territory, + ): + """Test for initialize territory.""" + + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + + # Create mock instances for dependencies + mock_cohort = mocker.create_autospec(AnimalCohort, instance=True) + mock_cohort.territory_size = 4 # Example size + centroid_key = 0 + + mock_get_community_by_key = mocker.Mock() + + # Set up the mock for bfs_territory to return a predefined set of cells + mock_bfs_territory.return_value = [0, 1, 3, 4] + + # Initialize the AnimalCommunity instance and set up grid dimensions + + animal_community_instance.data = mocker.Mock() + animal_community_instance.data.grid.cell_nx = 3 + animal_community_instance.data.grid.cell_ny = 3 + + # Call the method under test + animal_community_instance.initialize_territory( + mock_cohort, centroid_key, mock_get_community_by_key + ) + + # Check that bfs_territory was called with the correct parameters + mock_bfs_territory.assert_called_once_with(centroid_key, 4, 3, 3) + + # Check that AnimalTerritory was instantiated with the correct parameters + mock_animal_territory.assert_called_once_with( + [0, 1, 3, 4], mock_get_community_by_key + ) + + # Check that the territory was assigned to the cohort + assert mock_cohort.territory == mock_animal_territory.return_value + + # Ensure no additional unexpected calls were made + assert mock_bfs_territory.call_count == 1 + assert mock_animal_territory.call_count == 1 From 54639cb3ce527934477b5d564edf41fd80864278 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 15 Jul 2024 14:52:09 +0100 Subject: [PATCH 11/62] Fixed test_calculate_potential_consumed_mass. --- tests/models/animals/test_animal_cohorts.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 9acfed5d1..ea4ab6130 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -600,7 +600,8 @@ def test_calculate_potential_consumed_biomass( from virtual_ecosystem.models.animal.protocols import Resource # Mock the target plant - target_plant = mocker.MagicMock(spec=Resource, mass_current=mass_current) + target_plant = mocker.MagicMock(spec=Resource) + target_plant.mass_current = mass_current # Mock k_i_k to return the expected_biomass k_i_k_mock = mocker.patch( @@ -611,7 +612,12 @@ def test_calculate_potential_consumed_biomass( # Setup functional group mock to provide phi_herb_t functional_group_mock = mocker.MagicMock() functional_group_mock.diet = DietType("herbivore") - functional_group_mock.constants.phi_herb_t = phi_herb_t + constants_mock = mocker.MagicMock() + constants_mock.phi_herb_t = phi_herb_t + functional_group_mock.constants = constants_mock + + # Mock the adult_mass attribute + functional_group_mock.adult_mass = 50.0 # Example mass, adjust as needed # Initialize the AnimalCohort instance with mocked functional group cohort_instance = AnimalCohort( @@ -633,11 +639,11 @@ def test_calculate_potential_consumed_biomass( f"phi_herb_t={phi_herb_t}" ) - # verify that k_i_k was called with the correct parameters + # Verify that k_i_k was called with the correct parameters A_cell = 1.0 k_i_k_mock.assert_called_once_with(alpha, phi_herb_t, mass_current, A_cell) - def calculate_total_handling_time_for_herbivory( + def test_calculate_total_handling_time_for_herbivory( self, mocker, herbivore_cohort_instance, plant_list_instance ): """Test aggregation of handling times across all available plant resources.""" From 4c7fe4c37b66e32afcab0179074b1ed81d07066a Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 16 Jul 2024 15:00:10 +0100 Subject: [PATCH 12/62] Reworking of types and protocols. --- .../models/animal/animal_cohorts.py | 26 ++++++++------ .../models/animal/animal_communities.py | 27 ++++++++------ .../models/animal/animal_territories.py | 36 +++++++++---------- virtual_ecosystem/models/animal/protocols.py | 29 ++++++++++++++- 4 files changed, 78 insertions(+), 40 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index ff1c763d2..3a26f4d97 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -223,7 +223,7 @@ def increase_age(self, dt: timedelta64) -> None: self.is_mature = True self.time_to_maturity = self.age - def die_individual(self, number_dead: int, carcass_pool: CarcassPool) -> None: + def die_individual(self, number_dead: int, carcass_pool: DecayPool) -> None: """The function to reduce the number of individuals in the cohort through death. Currently, all cohorts are crafted as single km2 grid cohorts. This means that @@ -503,7 +503,7 @@ def calculate_total_handling_time_for_predation(self) -> float: ) def F_i_j_individual( - self, animal_list: Sequence[AnimalCohort], target_cohort: AnimalCohort + self, animal_list: Sequence[Consumer], target_cohort: Consumer ) -> float: """Method to determine instantaneous predation rate on cohort j. @@ -528,7 +528,7 @@ def F_i_j_individual( return N_i * (k_target / (1 + total_handling_t)) * (1 / N_target) def calculate_consumed_mass_predation( - self, animal_list: Sequence[AnimalCohort], target_cohort: AnimalCohort + self, animal_list: Sequence[Consumer], target_cohort: Consumer ) -> float: """Calculates the mass to be consumed from a prey cohort by the predator. @@ -563,7 +563,7 @@ def calculate_consumed_mass_predation( def delta_mass_predation( self, - animal_list: Sequence[AnimalCohort], + animal_list: Sequence[Consumer], excrement_pool: DecayPool, carcass_pool: CarcassPool, ) -> float: @@ -571,6 +571,8 @@ def delta_mass_predation( This is Madingley's delta_assimilation_mass_predation + TODO: move defecate + Args: animal_list: A sequence of animal cohorts that can be consumed by the predator. @@ -583,11 +585,15 @@ def delta_mass_predation( total_consumed_mass = 0.0 # Initialize the total consumed mass - for cohort in animal_list: + for prey_cohort in animal_list: # Calculate the mass to be consumed from this cohort - consumed_mass = self.calculate_consumed_mass_predation(animal_list, cohort) + consumed_mass = self.calculate_consumed_mass_predation( + animal_list, prey_cohort + ) # Call get_eaten on the prey cohort to update its mass and individuals - actual_consumed_mass = cohort.get_eaten(consumed_mass, self, carcass_pool) + actual_consumed_mass = prey_cohort.get_eaten( + consumed_mass, self, carcass_pool + ) # Update total mass gained by the predator total_consumed_mass += actual_consumed_mass @@ -652,7 +658,7 @@ def delta_mass_herbivory( def forage_cohort( self, plant_list: Sequence[Resource], - animal_list: Sequence[AnimalCohort], + animal_list: Sequence[Consumer], excrement_pool: DecayPool, carcass_pool: CarcassPool, ) -> None: @@ -687,7 +693,7 @@ def forage_cohort( # Update the predator's mass with the total gained mass self.eat(consumed_mass) - def theta_i_j(self, animal_list: Sequence[AnimalCohort]) -> float: + def theta_i_j(self, animal_list: Sequence[Consumer]) -> float: """Cumulative density method for delta_mass_predation. The cumulative density of organisms with a mass lying within the same predator @@ -696,7 +702,7 @@ def theta_i_j(self, animal_list: Sequence[AnimalCohort]) -> float: Madingley TODO: current format makes no sense, dig up the details in the supp - TODO: update A_cell with real reference to grid zie + TODO: update A_cell with real reference to grid size TODO: update name Args: diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 0a2c4999d..d57ab22e4 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -10,7 +10,7 @@ import importlib import random -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, MutableSequence, Sequence from itertools import chain from math import ceil @@ -77,6 +77,11 @@ def __init__( """A pool for animal carcasses within the community.""" self.excrement_pool: ExcrementPool = ExcrementPool(10000.0, 0.0) """A pool for excrement within the community.""" + self.plant_community: PlantResources = PlantResources( + data=self.data, + cell_id=self.community_key, + constants=self.constants, + ) @property def all_animal_cohorts(self) -> Iterable[AnimalCohort]: @@ -325,17 +330,17 @@ def forage_community(self) -> None: """ # Generate the plant resources for foraging. - plant_community: PlantResources = PlantResources( - data=self.data, - cell_id=self.community_key, - constants=self.constants, - ) - plant_list = [plant_community] + plant_list: Sequence = [self.plant_community] for consumer_cohort in self.all_animal_cohorts: # Prepare the prey list for the consumer cohort - prey_list = self.collect_prey(consumer_cohort) + if consumer_cohort.territory is None: + raise ValueError("The cohort's territory hasn't been defined.") + prey_list = consumer_cohort.territory.get_prey(consumer_cohort) + plant_list = consumer_cohort.territory.get_plant_resources() + # excrement_list = consumer_cohort.territory.get_excrement_pools() + # carcass_list = consumer_cohort.territory.get_carcass_pools() # Initiate foraging for the consumer cohort with the prepared resources consumer_cohort.forage_cohort( @@ -349,7 +354,9 @@ def forage_community(self) -> None: if consumer_cohort.individuals == 0: self.remove_dead_cohort(consumer_cohort) - def collect_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: + def collect_prey( + self, consumer_cohort: AnimalCohort + ) -> MutableSequence[AnimalCohort]: """Collect suitable prey for a given consumer cohort. This is a helper function for forage_community to isolate the prey selection @@ -364,7 +371,7 @@ def collect_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: TODO: MGO - collect prey over territory """ - prey: list = [] + prey: MutableSequence = [] for ( prey_functional_group, potential_prey_cohorts, diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 8c55f60e6..27fa1f479 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -1,12 +1,14 @@ """The ''animal'' module provides animal module functionality.""" # noqa: #D205, D415 -from collections.abc import Callable +from collections.abc import Callable, MutableSequence, Sequence from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool from virtual_ecosystem.models.animal.plant_resources import PlantResources +# from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool, Resource + class AnimalTerritory: """This class defines a territory occupied by an animal cohort. @@ -37,13 +39,13 @@ def __init__( """A list of grid cells present in the territory.""" self.get_community_by_key = get_community_by_key """A list of animal communities present in the territory.""" - self.territory_prey: list[AnimalCohort] = [] + self.territory_prey: Sequence[AnimalCohort] = [] """A list of animal prey present in the territory.""" - self.territory_plants: list[PlantResources] = [] + self.territory_plants: Sequence[PlantResources] = [] """A list of plant resources present in the territory.""" - self.territory_excrement: list[ExcrementPool] = [] + self.territory_excrement: Sequence[ExcrementPool] = [] """A list of excrement pools present in the territory.""" - self.territory_carcasses: list[CarcassPool] = [] + self.territory_carcasses: Sequence[CarcassPool] = [] """A list of carcass pools present in the territory.""" def update_territory(self, consumer_cohort: AnimalCohort) -> None: @@ -57,9 +59,9 @@ def update_territory(self, consumer_cohort: AnimalCohort) -> None: self.territory_prey = self.get_prey(consumer_cohort) self.territory_plants = self.get_plant_resources() self.territory_excrement = self.get_excrement_pools() - self.territory_carcasses = self.get_carcass_pool() + self.territory_carcasses = self.get_carcass_pools() - def get_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: + def get_prey(self, consumer_cohort: AnimalCohort) -> Sequence[AnimalCohort]: """Collect suitable prey from all grid cells in the territory. TODO: This is probably not the best way to go about this. Maybe alter collect @@ -72,13 +74,13 @@ def get_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: Returns: A list of AnimalCohorts that can be preyed upon. """ - prey = [] + prey: MutableSequence = [] for cell_id in self.grid_cell_keys: community = self.get_community_by_key(cell_id) prey.extend(community.collect_prey(consumer_cohort)) return prey - def get_plant_resources(self) -> list[PlantResources]: + def get_plant_resources(self) -> MutableSequence[PlantResources]: """Collect plant resources from all grid cells in the territory. TODO: Update internal plant resource generation with a real link to the plant @@ -87,35 +89,31 @@ def get_plant_resources(self) -> list[PlantResources]: Returns: A list of PlantResources available in the territory. """ - plant_resources = [] + plant_resources: MutableSequence = [] for cell_id in self.grid_cell_keys: community = self.get_community_by_key(cell_id) - plant_resources.append( - PlantResources( - data=community.data, cell_id=cell_id, constants=community.constants - ) - ) + plant_resources.append(community.plant_community) return plant_resources - def get_excrement_pools(self) -> list[ExcrementPool]: + def get_excrement_pools(self) -> MutableSequence[ExcrementPool]: """Combine excrement pools from all grid cells in the territory. Returns: A list of ExcrementPools combined from all grid cells. """ - total_excrement = [] + total_excrement: MutableSequence = [] for cell_id in self.grid_cell_keys: community = self.get_community_by_key(cell_id) total_excrement.append(community.excrement_pool) return total_excrement - def get_carcass_pool(self) -> list[CarcassPool]: + def get_carcass_pools(self) -> MutableSequence[CarcassPool]: """Combine carcass pools from all grid cells in the territory. Returns: A list of CarcassPools combined from all grid cells. """ - total_carcass = [] + total_carcass: MutableSequence = [] for cell_id in self.grid_cell_keys: community = self.get_community_by_key(cell_id) total_carcass.append(community.carcass_pool) diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index a2077201f..42e8dddca 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -3,6 +3,7 @@ :mod:`~virtual_ecosystem.models.animal` module. """ # noqa: D205 +from collections.abc import Sequence from typing import Protocol from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -13,6 +14,16 @@ class Consumer(Protocol): functional_group: FunctionalGroup individuals: int + mass_current: float + + def get_eaten( + self, + potential_consumed_mass: float, + predator: "Consumer", + carcass_pool: "DecayPool", + ) -> float: + """The get_eaten method partially defines a consumer.""" + ... class Pool(Protocol): @@ -49,4 +60,20 @@ class Territory(Protocol): """ - grid_cell_keys: list[int] + grid_cell_keys: Sequence[int] + + def get_prey(self, consumer_cohort: Consumer) -> Sequence[Consumer]: + """The get_prey method partially defines a territory.""" + ... + + def get_plant_resources(self) -> Sequence[Resource]: + """The get_prey method partially defines a territory.""" + ... + + def get_excrement_pools(self) -> Sequence[DecayPool]: + """The get_prey method partially defines a territory.""" + ... + + def get_carcass_pools(self) -> Sequence[DecayPool]: + """The get_prey method partially defines a territory.""" + ... From 1766d883d8367c96d19c2fb390d780f0e07c288e Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 16 Jul 2024 16:36:12 +0100 Subject: [PATCH 13/62] Updated defecate for all pools in territory. --- .../models/animal/animal_cohorts.py | 55 +++++++++++-------- .../models/animal/animal_communities.py | 6 +- virtual_ecosystem/models/animal/protocols.py | 2 +- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 3a26f4d97..cd59bc71d 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -172,10 +172,10 @@ def respire(self, excreta_mass: float) -> float: def defecate( self, - excrement_pool: DecayPool, + excrement_pools: Sequence[DecayPool], mass_consumed: float, ) -> None: - """Transfer waste mass from an animal cohort to the excrement pool. + """Transfer waste mass from an animal cohort to the excrement pools. Currently, this function is in an inbetween state where mass is removed from the animal cohort but it is recieved by the litter pool as energy. This will be @@ -187,21 +187,32 @@ def defecate( TODO: MGO - rework for territories (if need be) Args: - excrement_pool: The local ExcrementSoil pool in which waste is deposited. + excrement_pools: The ExcrementPool objects in the cohort's territory in + which waste is deposited. mass_consumed: The amount of mass flowing through cohort digestion. """ - # Find total waste mass, the total amount of waste is then found by the - # average cohort member * number individuals. - waste_energy = mass_consumed * self.functional_group.conversion_efficiency + # the number of communities over which the feces are to be distributed + number_communities = len(excrement_pools) - # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_energy += ( - (1 - self.decay_fraction_excrement) * waste_energy * self.individuals - ) - excrement_pool.decomposed_energy += ( - self.decay_fraction_excrement * waste_energy * self.individuals + # Find total waste mass, the total amount of waste is found by the + # average cohort member * number individuals. + waste_energy = ( + mass_consumed + * self.functional_group.conversion_efficiency + * self.individuals ) + waste_energy_per_community = waste_energy / number_communities + + for excrement_pool in excrement_pools: + # This total waste is then split between decay and scavengeable excrement + excrement_pool.scavengeable_energy += ( + 1 - self.decay_fraction_excrement + ) * waste_energy_per_community + excrement_pool.decomposed_energy += ( + self.decay_fraction_excrement * waste_energy_per_community + ) + def increase_age(self, dt: timedelta64) -> None: """The function to modify cohort age as time passes and flag maturity. @@ -564,7 +575,7 @@ def calculate_consumed_mass_predation( def delta_mass_predation( self, animal_list: Sequence[Consumer], - excrement_pool: DecayPool, + excrement_pools: Sequence[DecayPool], carcass_pool: CarcassPool, ) -> float: """This method handles mass assimilation from predation. @@ -576,7 +587,7 @@ def delta_mass_predation( Args: animal_list: A sequence of animal cohorts that can be consumed by the predator. - excrement_pool: A pool representing the excrement in the grid cell. + excrement_pools: The pools representing the excrement in the territory. carcass_pool: A pool representing the animal carcasses in the grid cell. Returns: @@ -598,7 +609,7 @@ def delta_mass_predation( total_consumed_mass += actual_consumed_mass # Process waste generated from predation, separate from herbivory b/c diff waste - self.defecate(excrement_pool, total_consumed_mass) + self.defecate(excrement_pools, total_consumed_mass) return total_consumed_mass def calculate_consumed_mass_herbivory( @@ -629,7 +640,7 @@ def calculate_consumed_mass_herbivory( return consumed_mass def delta_mass_herbivory( - self, plant_list: Sequence[Resource], excrement_pool: DecayPool + self, plant_list: Sequence[Resource], excrement_pools: Sequence[DecayPool] ) -> float: """This method handles mass assimilation from herbivory. @@ -637,7 +648,7 @@ def delta_mass_herbivory( Args: plant_list: A sequence of plant resources available for herbivory. - excrement_pool: A pool representing the excrement in the grid cell. + excrement_pools: The pools representing the excrement in the territory. Returns: A float of the total plant mass consumed by the animal cohort in g. @@ -649,7 +660,7 @@ def delta_mass_herbivory( # Calculate the mass to be consumed from this plant consumed_mass = self.calculate_consumed_mass_herbivory(plant_list, plant) # Update the plant resource's state based on consumed mass - actual_consumed_mass = plant.get_eaten(consumed_mass, self, excrement_pool) + actual_consumed_mass = plant.get_eaten(consumed_mass, self, excrement_pools) # Update total mass gained by the herbivore total_consumed_mass += actual_consumed_mass @@ -659,7 +670,7 @@ def forage_cohort( self, plant_list: Sequence[Resource], animal_list: Sequence[Consumer], - excrement_pool: DecayPool, + excrement_pools: Sequence[DecayPool], carcass_pool: CarcassPool, ) -> None: """This function handles selection of resources from a list for consumption. @@ -667,7 +678,7 @@ def forage_cohort( Args: plant_list: A sequence of plant resources available for herbivory. animal_list: A sequence of animal cohorts available for predation. - excrement_pool: A pool representing the excrement in the grid cell. + excrement_pools: A pool representing the excrement in the grid cell. carcass_pool: A pool representing the carcasses in the grid cell. Return: @@ -680,7 +691,7 @@ def forage_cohort( # Herbivore diet if self.functional_group.diet == DietType.HERBIVORE and plant_list: consumed_mass = self.delta_mass_herbivory( - plant_list, excrement_pool + plant_list, excrement_pools ) # Directly modifies the plant mass self.eat(consumed_mass) # Accumulate net mass gain from each plant @@ -688,7 +699,7 @@ def forage_cohort( elif self.functional_group.diet == DietType.CARNIVORE and animal_list: # Calculate the mass gained from predation consumed_mass = self.delta_mass_predation( - animal_list, excrement_pool, carcass_pool + animal_list, excrement_pools, carcass_pool ) # Update the predator's mass with the total gained mass self.eat(consumed_mass) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index d57ab22e4..a33c5ffeb 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -339,14 +339,14 @@ def forage_community(self) -> None: raise ValueError("The cohort's territory hasn't been defined.") prey_list = consumer_cohort.territory.get_prey(consumer_cohort) plant_list = consumer_cohort.territory.get_plant_resources() - # excrement_list = consumer_cohort.territory.get_excrement_pools() + excrement_list = consumer_cohort.territory.get_excrement_pools() # carcass_list = consumer_cohort.territory.get_carcass_pools() # Initiate foraging for the consumer cohort with the prepared resources consumer_cohort.forage_cohort( plant_list=plant_list, animal_list=prey_list, - excrement_pool=self.excrement_pool, + excrement_pools=excrement_list, carcass_pool=self.carcass_pool, ) @@ -366,7 +366,7 @@ def collect_prey( consumer_cohort: The AnimalCohort for which a prey list is being collected Returns: - A list of AnimalCohorts that can be preyed upon. + A sequence of AnimalCohorts that can be preyed upon. TODO: MGO - collect prey over territory diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 42e8dddca..aedff9c2a 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -46,7 +46,7 @@ class Resource(Protocol): mass_current: float def get_eaten( - self, consumed_mass: float, consumer: Consumer, pool: DecayPool + self, consumed_mass: float, consumer: Consumer, pools: Sequence[DecayPool] ) -> float: """The get_eaten method defines a resource.""" ... From aaa75e5ed97565b3ace53e42f0206a897e126ef0 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 18 Jul 2024 14:36:13 +0100 Subject: [PATCH 14/62] Many small changes to get typing to work between files/classes. This adds default classes for territory and community. --- .../models/animal/animal_cohorts.py | 59 +++++++------ .../models/animal/animal_communities.py | 84 +++++++++++++++++-- .../models/animal/animal_territories.py | 53 ++++++++---- virtual_ecosystem/models/animal/protocols.py | 39 +++++++-- 4 files changed, 174 insertions(+), 61 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index cd59bc71d..8180fbb76 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -1,10 +1,4 @@ -"""The ''animal'' module provides animal module functionality. - -Notes: -- assume each grid = 1 km2 -- assume each tick = 1 day (28800s) -- damuth ~ 4.23*mass**(-3/4) indiv / km2 -""" +"""The ''animal'' module provides animal module functionality.""" from __future__ import annotations @@ -36,6 +30,7 @@ def __init__( mass: float, age: float, individuals: int, + territory: Territory, constants: AnimalConsts = AnimalConsts(), ) -> None: if age < 0: @@ -57,6 +52,8 @@ def __init__( """The age of the animal cohort [days].""" self.individuals = individuals """The number of individuals in this cohort.""" + self.territory = territory + """The territory of animal communities occupied by the cohort.""" self.constants = constants """Animal constants.""" self.damuth_density: int = sf.damuths_law( @@ -81,8 +78,6 @@ def __init__( """The identification of useable food resources.""" self.territory_size = sf.territory_size(self.functional_group.adult_mass) """The size in hectares of the animal cohorts territory.""" - self.territory: Territory | None = None - """The AnimalTerritory object associated with the cohort.""" # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement @@ -267,25 +262,32 @@ def die_individual(self, number_dead: int, carcass_pool: DecayPool) -> None: ) * carcass_mass carcass_pool.decomposed_energy += self.decay_fraction_carcasses * carcass_mass - def update_carcass_pool(self, carcass_mass: float, carcass_pool: DecayPool) -> None: - """Updates the carcass pool based on consumed mass and predator's efficiency. + def update_carcass_pool( + self, carcass_mass: float, carcass_pools: Sequence[DecayPool] + ) -> None: + """Updates the carcass pools based on consumed mass and predator's efficiency. Args: carcass_mass: The total mass consumed from the prey cohort. - carcass_pool: The pool to which remains of eaten individuals are delivered. + carcass_pools: The pools to which remains of eaten individuals are + delivered. """ - - # Update the carcass pool with the remainder - carcass_pool.scavengeable_energy += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass - carcass_pool.decomposed_energy += self.decay_fraction_carcasses * carcass_mass + number_carcass_pools = len(carcass_pools) + carcass_mass_per_pool = carcass_mass / number_carcass_pools + + for carcass_pool in carcass_pools: + # Update the carcass pool with the remainder + carcass_pool.scavengeable_energy += ( + 1 - self.decay_fraction_carcasses + ) * carcass_mass_per_pool + carcass_pool.decomposed_energy += ( + self.decay_fraction_carcasses * carcass_mass_per_pool + ) def get_eaten( self, potential_consumed_mass: float, predator: Consumer, - carcass_pool: DecayPool, ) -> float: """Removes individuals according to mass demands of a predation event. @@ -324,8 +326,13 @@ def get_eaten( # Update the number of individuals in the prey cohort self.individuals -= actual_individuals_killed + # Find the intersection of prey and predator territories + intersection_carcass_pools = self.territory.find_intersecting_carcass_pools( + predator.territory + ) + # Update the carcass pool with carcass mass - self.update_carcass_pool(carcass_mass, carcass_pool) + self.update_carcass_pool(carcass_mass, intersection_carcass_pools) return actual_mass_consumed @@ -576,7 +583,6 @@ def delta_mass_predation( self, animal_list: Sequence[Consumer], excrement_pools: Sequence[DecayPool], - carcass_pool: CarcassPool, ) -> float: """This method handles mass assimilation from predation. @@ -588,7 +594,6 @@ def delta_mass_predation( animal_list: A sequence of animal cohorts that can be consumed by the predator. excrement_pools: The pools representing the excrement in the territory. - carcass_pool: A pool representing the animal carcasses in the grid cell. Returns: The change in mass experienced by the predator. @@ -602,9 +607,7 @@ def delta_mass_predation( animal_list, prey_cohort ) # Call get_eaten on the prey cohort to update its mass and individuals - actual_consumed_mass = prey_cohort.get_eaten( - consumed_mass, self, carcass_pool - ) + actual_consumed_mass = prey_cohort.get_eaten(consumed_mass, self) # Update total mass gained by the predator total_consumed_mass += actual_consumed_mass @@ -671,7 +674,6 @@ def forage_cohort( plant_list: Sequence[Resource], animal_list: Sequence[Consumer], excrement_pools: Sequence[DecayPool], - carcass_pool: CarcassPool, ) -> None: """This function handles selection of resources from a list for consumption. @@ -679,7 +681,6 @@ def forage_cohort( plant_list: A sequence of plant resources available for herbivory. animal_list: A sequence of animal cohorts available for predation. excrement_pools: A pool representing the excrement in the grid cell. - carcass_pool: A pool representing the carcasses in the grid cell. Return: A float value of the net change in consumer mass due to foraging. @@ -698,9 +699,7 @@ def forage_cohort( # Carnivore diet elif self.functional_group.diet == DietType.CARNIVORE and animal_list: # Calculate the mass gained from predation - consumed_mass = self.delta_mass_predation( - animal_list, excrement_pools, carcass_pool - ) + consumed_mass = self.delta_mass_predation(animal_list, excrement_pools) # Update the predator's mass with the total gained mass self.eat(consumed_mass) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index a33c5ffeb..38035a106 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -1,10 +1,4 @@ -"""The ''animal'' module provides animal module functionality. - -Notes: -- assume each grid = 1 km2 -- assume each tick = 1 day (28800s) -- damuth ~ 4.23*mass**(-3/4) indiv / km2 -""" +"""The ''animal'' module provides animal module functionality.""" from __future__ import annotations @@ -27,6 +21,12 @@ get_functional_group_by_name, ) from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.protocols import ( + Consumer, + DecayPool, + Resource, + Territory, +) from virtual_ecosystem.models.animal.scaling_functions import damuths_law @@ -159,6 +159,7 @@ def populate_community(self) -> None: functional_group.adult_mass, 0.0, individuals, + DefaultTerritory(), self.constants, ) # add the cohort to the community @@ -288,6 +289,7 @@ def birth(self, parent_cohort: AnimalCohort) -> None: parent_cohort.functional_group.birth_mass, 0.0, number_offspring, + DefaultTerritory(), self.constants, ) @@ -340,14 +342,12 @@ def forage_community(self) -> None: prey_list = consumer_cohort.territory.get_prey(consumer_cohort) plant_list = consumer_cohort.territory.get_plant_resources() excrement_list = consumer_cohort.territory.get_excrement_pools() - # carcass_list = consumer_cohort.territory.get_carcass_pools() # Initiate foraging for the consumer cohort with the prepared resources consumer_cohort.forage_cohort( plant_list=plant_list, animal_list=prey_list, excrement_pools=excrement_list, - carcass_pool=self.carcass_pool, ) # Check if the cohort has been depleted to zero individuals post-foraging @@ -488,6 +488,7 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: adult_functional_group.birth_mass, 0.0, larval_cohort.individuals, + DefaultTerritory(), self.constants, ) @@ -514,3 +515,68 @@ def metamorphose_community(self) -> None: and (cohort.mass_current >= cohort.functional_group.adult_mass) ): self.metamorphose(cohort) + + +class DefaultCommunity(AnimalCommunity): + """A default community that represents an empty or non-functional state.""" + + def __init__(self) -> None: + self.functional_groups: tuple[FunctionalGroup, ...] = () + self.data: Data = self.data + self.community_key: int = -1 + self.neighbouring_keys: list[int] = [] + self.constants: AnimalConsts = AnimalConsts() + self.carcass_pool: CarcassPool = CarcassPool(10000.0, 0.0) + """A pool for animal carcasses within the community.""" + self.excrement_pool: ExcrementPool = ExcrementPool(10000.0, 0.0) + """A pool for excrement within the community.""" + self.plant_community: PlantResources = PlantResources( + data=self.data, + cell_id=self.community_key, + constants=self.constants, + ) + + def collect_prey( + self, consumer_cohort: AnimalCohort + ) -> MutableSequence[AnimalCohort]: + """Default method.""" + return [] + + def get_community_by_key(self, key: int) -> AnimalCommunity: + """Default method.""" + return self + + +class DefaultTerritory(Territory): + """A default territory that represents an empty or non-functional state.""" + + def __init__(self) -> None: + """Default method.""" + self.grid_cell_keys: list[int] = [] + self._get_community_by_key = lambda key: DefaultCommunity() + + def update_territory(self, consumer_cohort: Consumer) -> None: + """Default method.""" + pass + + def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: + """Default method.""" + return [] + + def get_plant_resources(self) -> MutableSequence[Resource]: + """Default method.""" + return [] + + def get_excrement_pools(self) -> MutableSequence[DecayPool]: + """Default method.""" + return [] + + def get_carcass_pools(self) -> MutableSequence[DecayPool]: + """Default method.""" + return [] + + def find_intersecting_carcass_pools( + self, animal_territory: Territory + ) -> MutableSequence[DecayPool]: + """Default method.""" + return [] diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 27fa1f479..b2f9dfb5a 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -2,12 +2,13 @@ from collections.abc import Callable, MutableSequence, Sequence -from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool -from virtual_ecosystem.models.animal.plant_resources import PlantResources - -# from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool, Resource +from virtual_ecosystem.models.animal.protocols import ( + Community, + Consumer, + DecayPool, + Resource, + Territory, +) class AnimalTerritory: @@ -32,23 +33,23 @@ class AnimalTerritory: def __init__( self, grid_cell_keys: list[int], - get_community_by_key: Callable[[int], AnimalCommunity], + get_community_by_key: Callable[[int], Community], ) -> None: # The constructor of the AnimalTerritory class. self.grid_cell_keys = grid_cell_keys """A list of grid cells present in the territory.""" self.get_community_by_key = get_community_by_key """A list of animal communities present in the territory.""" - self.territory_prey: Sequence[AnimalCohort] = [] + self.territory_prey: Sequence[Consumer] = [] """A list of animal prey present in the territory.""" - self.territory_plants: Sequence[PlantResources] = [] + self.territory_plants: Sequence[Resource] = [] """A list of plant resources present in the territory.""" - self.territory_excrement: Sequence[ExcrementPool] = [] + self.territory_excrement: Sequence[DecayPool] = [] """A list of excrement pools present in the territory.""" - self.territory_carcasses: Sequence[CarcassPool] = [] + self.territory_carcasses: Sequence[DecayPool] = [] """A list of carcass pools present in the territory.""" - def update_territory(self, consumer_cohort: AnimalCohort) -> None: + def update_territory(self, consumer_cohort: Consumer) -> None: """Update territory details at initialization and after migration. Args: @@ -61,7 +62,7 @@ def update_territory(self, consumer_cohort: AnimalCohort) -> None: self.territory_excrement = self.get_excrement_pools() self.territory_carcasses = self.get_carcass_pools() - def get_prey(self, consumer_cohort: AnimalCohort) -> Sequence[AnimalCohort]: + def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """Collect suitable prey from all grid cells in the territory. TODO: This is probably not the best way to go about this. Maybe alter collect @@ -80,7 +81,7 @@ def get_prey(self, consumer_cohort: AnimalCohort) -> Sequence[AnimalCohort]: prey.extend(community.collect_prey(consumer_cohort)) return prey - def get_plant_resources(self) -> MutableSequence[PlantResources]: + def get_plant_resources(self) -> MutableSequence[Resource]: """Collect plant resources from all grid cells in the territory. TODO: Update internal plant resource generation with a real link to the plant @@ -95,7 +96,7 @@ def get_plant_resources(self) -> MutableSequence[PlantResources]: plant_resources.append(community.plant_community) return plant_resources - def get_excrement_pools(self) -> MutableSequence[ExcrementPool]: + def get_excrement_pools(self) -> MutableSequence[DecayPool]: """Combine excrement pools from all grid cells in the territory. Returns: @@ -107,7 +108,7 @@ def get_excrement_pools(self) -> MutableSequence[ExcrementPool]: total_excrement.append(community.excrement_pool) return total_excrement - def get_carcass_pools(self) -> MutableSequence[CarcassPool]: + def get_carcass_pools(self) -> MutableSequence[DecayPool]: """Combine carcass pools from all grid cells in the territory. Returns: @@ -119,6 +120,26 @@ def get_carcass_pools(self) -> MutableSequence[CarcassPool]: total_carcass.append(community.carcass_pool) return total_carcass + def find_intersecting_carcass_pools( + self, animal_territory: "Territory" + ) -> MutableSequence[DecayPool]: + """Find the carcass pools of the intersection of two territories. + + Args: + animal_territory: Another AnimalTerritory to find the intersection with. + + Returns: + A list of CarcassPools in the intersecting grid cells. + """ + intersecting_keys = set(self.grid_cell_keys) & set( + animal_territory.grid_cell_keys + ) + intersecting_carcass_pools = [] + for cell_id in intersecting_keys: + community = self.get_community_by_key(cell_id) + intersecting_carcass_pools.append(community.carcass_pool) + return intersecting_carcass_pools + def bfs_territory( centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index aedff9c2a..1fb79ae37 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -3,24 +3,45 @@ :mod:`~virtual_ecosystem.models.animal` module. """ # noqa: D205 -from collections.abc import Sequence +from collections.abc import MutableSequence, Sequence from typing import Protocol +from virtual_ecosystem.core.data import Data from virtual_ecosystem.models.animal.functional_group import FunctionalGroup +class Community(Protocol): + """This is the protocol for defining communities.""" + + functional_groups: list[FunctionalGroup] + data: Data + community_key: int + neighbouring_keys: list[int] + carcass_pool: "DecayPool" + excrement_pool: "DecayPool" + plant_community: "Resource" + + def get_community_by_key(self, key: int) -> "Community": + """Method to return a designated Community by integer key.""" + ... + + def collect_prey(self, consumer_cohort: "Consumer") -> MutableSequence["Consumer"]: + """Method to return a list of prey cohorts.""" + ... + + class Consumer(Protocol): """This is the protocol for defining consumers (currently just AnimalCohort).""" functional_group: FunctionalGroup individuals: int mass_current: float + territory: "Territory" def get_eaten( self, potential_consumed_mass: float, predator: "Consumer", - carcass_pool: "DecayPool", ) -> float: """The get_eaten method partially defines a consumer.""" ... @@ -62,18 +83,24 @@ class Territory(Protocol): grid_cell_keys: Sequence[int] - def get_prey(self, consumer_cohort: Consumer) -> Sequence[Consumer]: + def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """The get_prey method partially defines a territory.""" ... - def get_plant_resources(self) -> Sequence[Resource]: + def get_plant_resources(self) -> MutableSequence[Resource]: """The get_prey method partially defines a territory.""" ... - def get_excrement_pools(self) -> Sequence[DecayPool]: + def get_excrement_pools(self) -> MutableSequence[DecayPool]: """The get_prey method partially defines a territory.""" ... - def get_carcass_pools(self) -> Sequence[DecayPool]: + def get_carcass_pools(self) -> MutableSequence[DecayPool]: """The get_prey method partially defines a territory.""" ... + + def find_intersecting_carcass_pools( + self, animal_territory: "Territory" + ) -> MutableSequence[DecayPool]: + """The find_intersecting_carcass_pools method partially defines a territory.""" + ... From 4ceba3cbeb6d2c645c9839af013c32f9b494c874 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 18 Jul 2024 15:13:25 +0100 Subject: [PATCH 15/62] die_individual now calls update_carcass_pools. --- .../models/animal/animal_cohorts.py | 20 ++++++++----------- .../models/animal/animal_communities.py | 11 +++++++--- virtual_ecosystem/models/animal/protocols.py | 1 + 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 8180fbb76..f05b412d2 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -11,7 +11,6 @@ from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_traits import DietType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import CarcassPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup from virtual_ecosystem.models.animal.protocols import ( Consumer, @@ -229,7 +228,9 @@ def increase_age(self, dt: timedelta64) -> None: self.is_mature = True self.time_to_maturity = self.age - def die_individual(self, number_dead: int, carcass_pool: DecayPool) -> None: + def die_individual( + self, number_dead: int, carcass_pools: Sequence[DecayPool] + ) -> None: """The function to reduce the number of individuals in the cohort through death. Currently, all cohorts are crafted as single km2 grid cohorts. This means that @@ -242,12 +243,11 @@ def die_individual(self, number_dead: int, carcass_pool: DecayPool) -> None: fixed once the litter pools are updated for mass. TODO: Rework after update litter pools for mass - TODO: MGO - rework for territories Args: number_dead: The number of individuals by which to decrease the population count. - carcass_pool: The resident pool of animal carcasses to which the dead + carcass_pools: The resident pool of animal carcasses to which the dead individuals are delivered. """ @@ -256,11 +256,7 @@ def die_individual(self, number_dead: int, carcass_pool: DecayPool) -> None: # Find total mass contained in the carcasses carcass_mass = number_dead * self.mass_current - # Split this mass between carcass decay, and scavengeable carcasses - carcass_pool.scavengeable_energy += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass - carcass_pool.decomposed_energy += self.decay_fraction_carcasses * carcass_mass + self.update_carcass_pool(carcass_mass, carcass_pools) def update_carcass_pool( self, carcass_mass: float, carcass_pools: Sequence[DecayPool] @@ -820,7 +816,7 @@ def migrate_juvenile_probability(self) -> float: return min(1.0, probability_of_dispersal) def inflict_non_predation_mortality( - self, dt: float, carcass_pool: CarcassPool + self, dt: float, carcass_pools: Sequence[DecayPool] ) -> None: """Inflict combined background, senescence, and starvation mortalities. @@ -829,7 +825,7 @@ def inflict_non_predation_mortality( Args: dt: The time passed in the timestep (days). - carcass_pool: The local carcass pool to which dead individuals go. + carcass_pools: The local carcass pool to which dead individuals go. """ @@ -866,4 +862,4 @@ def inflict_non_predation_mortality( number_dead = ceil(pop_size * (1 - exp(-u_t * dt))) # Remove the dead individuals from the cohort - self.die_individual(number_dead, carcass_pool) + self.die_individual(number_dead, carcass_pools) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 38035a106..4d1ae56a0 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -452,7 +452,9 @@ def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: """ number_of_days = float(dt / timedelta64(1, "D")) for cohort in self.all_animal_cohorts: - cohort.inflict_non_predation_mortality(number_of_days, self.carcass_pool) + cohort.inflict_non_predation_mortality( + number_of_days, cohort.territory.territory_carcasses + ) if cohort.individuals <= 0: cohort.is_alive = False self.remove_dead_cohort(cohort) @@ -476,7 +478,9 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: number_dead = ceil( larval_cohort.individuals * larval_cohort.constants.metamorph_mortality ) - larval_cohort.die_individual(number_dead, self.carcass_pool) + larval_cohort.die_individual( + number_dead, larval_cohort.territory.territory_carcasses + ) # collect the adult functional group adult_functional_group = get_functional_group_by_name( self.functional_groups, @@ -488,7 +492,7 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: adult_functional_group.birth_mass, 0.0, larval_cohort.individuals, - DefaultTerritory(), + larval_cohort.territory, self.constants, ) @@ -554,6 +558,7 @@ def __init__(self) -> None: """Default method.""" self.grid_cell_keys: list[int] = [] self._get_community_by_key = lambda key: DefaultCommunity() + self.territory_carcasses: Sequence[DecayPool] = [] def update_territory(self, consumer_cohort: Consumer) -> None: """Default method.""" diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 1fb79ae37..9920a51e7 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -82,6 +82,7 @@ class Territory(Protocol): """ grid_cell_keys: Sequence[int] + territory_carcasses: Sequence[DecayPool] def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """The get_prey method partially defines a territory.""" From bb70dfb7a7b42cb9c3100026dccadea27b3ef8e7 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 18 Jul 2024 15:29:21 +0100 Subject: [PATCH 16/62] Updated excrete for multi-grid. --- .../models/animal/animal_cohorts.py | 27 +++++++++++++------ .../models/animal/animal_communities.py | 8 +++--- virtual_ecosystem/models/animal/protocols.py | 1 + 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index f05b412d2..e67a69c13 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -126,7 +126,9 @@ def metabolize(self, temperature: float, dt: timedelta64) -> float: # in data object return actual_mass_metabolized * self.individuals - def excrete(self, excreta_mass: float, excrement_pool: DecayPool) -> None: + def excrete( + self, excreta_mass: float, excrement_pools: Sequence[DecayPool] + ) -> None: """Transfers nitrogenous metabolic wastes to the excrement pool. This method will not be fully implemented until the stoichiometric rework. All @@ -137,13 +139,25 @@ def excrete(self, excreta_mass: float, excrement_pool: DecayPool) -> None: Args: excreta_mass: The total mass of carbonaceous wastes excreted by the cohort. - excrement_pool: The pool of wastes to which the excreted nitrogenous wastes + excrement_pools: The pools of waste to which the excreted nitrogenous wastes flow. """ - excrement_pool.decomposed_energy += ( - excreta_mass * self.constants.nitrogen_excreta_proportion - ) + # the number of communities over which the feces are to be distributed + number_communities = len(excrement_pools) + + excreta_mass_per_community = ( + excreta_mass / number_communities + ) * self.constants.nitrogen_excreta_proportion + + for excrement_pool in excrement_pools: + # This total waste is then split between decay and scavengeable excrement + excrement_pool.scavengeable_energy += ( + 1 - self.decay_fraction_excrement + ) * excreta_mass_per_community + excrement_pool.decomposed_energy += ( + self.decay_fraction_excrement * excreta_mass_per_community + ) def respire(self, excreta_mass: float) -> float: """Transfers carbonaceous metabolic wastes to the atmosphere. @@ -178,7 +192,6 @@ def defecate( TODO: Rework after update litter pools for mass TODO: update for current conversion efficiency TODO: Update with stoichiometry - TODO: MGO - rework for territories (if need be) Args: excrement_pools: The ExcrementPool objects in the cohort's territory in @@ -290,8 +303,6 @@ def get_eaten( It finds the smallest whole number of prey required to satisfy the predators mass demands and caps at then caps it at the available population. - TODO: MGO - rework for territories - Args: potential_consumed_mass: The mass intended to be consumed by the predator. diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 4d1ae56a0..813ba0596 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -247,13 +247,13 @@ def birth(self, parent_cohort: AnimalCohort) -> None: A cohort can only reproduce if it has an excess of reproductive mass above a certain threshold. The offspring will be an identical cohort of adults - with age 0 and mass=birth_mass. + with age 0 and mass=birth_mass. A new territory, likely smaller b/c allometry, + is generated for the newborn cohort. The science here follows Madingley. TODO: Check whether Madingley discards excess reproductive mass. TODO: Rework birth mass for indirect developers. - TODO: MGO - cohorts are born at centroid Args: parent_cohort: The AnimalCohort instance which is producing a new cohort. @@ -421,7 +421,7 @@ def metabolize_community(self, temperature: float, dt: timedelta64) -> None: total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) cohort.excrete( metabolic_waste_mass, - self.excrement_pool, + cohort.territory.territory_excrement, ) # Update the total_animal_respiration for this community using community_key. @@ -468,7 +468,6 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: TODO: Build in a relationship between larval_cohort mass and adult cohort mass. TODO: Is adult_mass the correct mass threshold? TODO: If the time step drops below a month, this needs an intermediary stage. - TODO: MGO - metamorphose at centroid? Args: larval_cohort: The cohort in its larval stage to be transformed. @@ -559,6 +558,7 @@ def __init__(self) -> None: self.grid_cell_keys: list[int] = [] self._get_community_by_key = lambda key: DefaultCommunity() self.territory_carcasses: Sequence[DecayPool] = [] + self.territory_excrement: Sequence[DecayPool] = [] def update_territory(self, consumer_cohort: Consumer) -> None: """Default method.""" diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 9920a51e7..acb414925 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -83,6 +83,7 @@ class Territory(Protocol): grid_cell_keys: Sequence[int] territory_carcasses: Sequence[DecayPool] + territory_excrement: Sequence[DecayPool] def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """The get_prey method partially defines a territory.""" From 1e93fc453f0c6b8676cbc6ca46aeebf14d21652d Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 19 Jul 2024 15:48:17 +0100 Subject: [PATCH 17/62] Added a community.occupancy attribute to track cohorts partially in a community. --- .../models/animal/animal_communities.py | 40 +++++++++++++------ .../models/animal/animal_territories.py | 3 ++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 813ba0596..fb420e1c2 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -68,11 +68,16 @@ def __init__( """Callable get_community_by_key from AnimalModel.""" self.constants = constants """Animal constants.""" - self.animal_cohorts: dict[str, list[AnimalCohort]] = { k.name: [] for k in self.functional_groups } - """A dictionary of lists of animal cohort keyed by functional group.""" + """A dictionary of lists of animal cohorts keyed by functional group, containing + only those cohorts having their territory centroid in this community.""" + self.occupancy: dict[str, dict[AnimalCohort, float]] = { + k.name: {} for k in self.functional_groups + } + """A dictionary of dictionaries of animal cohorts keyed by functional group and + cohort, with the value being the occupancy percentage.""" self.carcass_pool: CarcassPool = CarcassPool(10000.0, 0.0) """A pool for animal carcasses within the community.""" self.excrement_pool: ExcrementPool = ExcrementPool(10000.0, 0.0) @@ -85,7 +90,7 @@ def __init__( @property def all_animal_cohorts(self) -> Iterable[AnimalCohort]: - """Get an iterable of all animal cohorts in the community. + """Get an iterable of all animal cohorts w/ proportion in the community. This property provides access to all the animal cohorts contained within this community class. @@ -93,7 +98,9 @@ def all_animal_cohorts(self) -> Iterable[AnimalCohort]: Returns: Iterable[AnimalCohort]: An iterable of AnimalCohort objects. """ - return chain.from_iterable(self.animal_cohorts.values()) + return chain.from_iterable( + cohort_dict.keys() for cohort_dict in self.occupancy.values() + ) def initialize_territory( self, @@ -130,10 +137,18 @@ def initialize_territory( ) # Generate the territory - territory = AnimalTerritory(territory_cells, get_community_by_key) + territory = AnimalTerritory(centroid_key, territory_cells, get_community_by_key) # Add the territory to the cohort's attributes cohort.territory = territory + # Update the occupancy of the cohort in each community within the territory + occupancy_percentage = 1.0 / len(territory_cells) + for cell_key in territory_cells: + community = get_community_by_key(cell_key) + community.occupancy[cohort.functional_group.name][cohort] = ( + occupancy_percentage + ) + def populate_community(self) -> None: """This function creates an instance of each functional group. @@ -162,9 +177,12 @@ def populate_community(self) -> None: DefaultTerritory(), self.constants, ) - # add the cohort to the community + # add the cohort to the community's list of animal cohorts @ centroid self.animal_cohorts[functional_group.name].append(cohort) + # add the cohort to the community with 100% occupancy initially + self.occupancy[functional_group.name][cohort] = 1.0 + # generate a territory for the cohort self.initialize_territory( cohort, @@ -216,7 +234,7 @@ def migrate_community(self) -> None: ) if not migrate: - return + continue destination_key = random.choice(self.neighbouring_keys) destination = self.get_community_by_key(destination_key) @@ -328,7 +346,6 @@ def forage_community(self) -> None: Cohorts with no remaining individuals post-foraging are marked for death. - TODO: MGO - forage over territory instead of community """ # Generate the plant resources for foraging. @@ -359,8 +376,8 @@ def collect_prey( ) -> MutableSequence[AnimalCohort]: """Collect suitable prey for a given consumer cohort. - This is a helper function for forage_community to isolate the prey selection - functionality. + This is a helper function for territory.get_prey, it filters suitable prey from + the total list of animal cohorts across the territory. Args: consumer_cohort: The AnimalCohort for which a prey list is being collected @@ -368,8 +385,6 @@ def collect_prey( Returns: A sequence of AnimalCohorts that can be preyed upon. - TODO: MGO - collect prey over territory - """ prey: MutableSequence = [] for ( @@ -407,7 +422,6 @@ def metabolize_community(self, temperature: float, dt: timedelta64) -> None: spatially explicit with multi-grid occupancy. TODO: Rework with stoichiometry - TODO: MGO - rework excretion for territories Args: temperature: Current air temperature (K). diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index b2f9dfb5a..332b29cf9 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -32,10 +32,13 @@ class AnimalTerritory: def __init__( self, + centroid: int, grid_cell_keys: list[int], get_community_by_key: Callable[[int], Community], ) -> None: # The constructor of the AnimalTerritory class. + self.centroid = centroid + """The centroid community of the territory (not technically a centroid).""" self.grid_cell_keys = grid_cell_keys """A list of grid cells present in the territory.""" self.get_community_by_key = get_community_by_key From a896d0f564ef58724508c1bf22660d654777173c Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 19 Jul 2024 16:09:54 +0100 Subject: [PATCH 18/62] Added abandon_communities method to animal territory. --- .../models/animal/animal_territories.py | 13 +++++++++++++ virtual_ecosystem/models/animal/protocols.py | 1 + 2 files changed, 14 insertions(+) diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 332b29cf9..ba7704b93 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -65,6 +65,19 @@ def update_territory(self, consumer_cohort: Consumer) -> None: self.territory_excrement = self.get_excrement_pools() self.territory_carcasses = self.get_carcass_pools() + def abandon_communities(self, cohort: Consumer) -> None: + """Removes the cohort from the occupancy of every community. + + This method is for use in death or re-initializing territories. + + Args: + cohort: The cohort to be removed from the occupancy lists. + """ + for cell_id in self.grid_cell_keys: + community = self.get_community_by_key(cell_id) + if cohort in community.occupancy[cohort.functional_group.name]: + del community.occupancy[cohort.functional_group.name][cohort] + def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """Collect suitable prey from all grid cells in the territory. diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index acb414925..4e44772ae 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -20,6 +20,7 @@ class Community(Protocol): carcass_pool: "DecayPool" excrement_pool: "DecayPool" plant_community: "Resource" + occupancy: dict[str, dict["Consumer", float]] def get_community_by_key(self, key: int) -> "Community": """Method to return a designated Community by integer key.""" From 56ee5d029210fca15bd0402e94cd6731064da04b Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 19 Jul 2024 16:27:47 +0100 Subject: [PATCH 19/62] Added a reinitialize_territory method and modified migrate. --- .../models/animal/animal_communities.py | 28 +++++++++++++++++-- .../models/animal/animal_territories.py | 13 ++++++--- virtual_ecosystem/models/animal/protocols.py | 4 +++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index fb420e1c2..2e657c240 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -149,6 +149,26 @@ def initialize_territory( occupancy_percentage ) + def reinitialize_territory( + self, + cohort: AnimalCohort, + centroid_key: int, + get_community_by_key: Callable[[int], AnimalCommunity], + ) -> None: + """This initializes the territory occupied by the cohort. + + TODO: update the territory size to cell number conversion using grid size + + Args: + cohort: The animal cohort occupying the territory. + centroid_key: The community key anchoring the territory. + get_community_by_key: The method for accessing animal communities by key. + """ + # remove existing occupancies + cohort.territory.abandon_communities(cohort) + # reinitialize the territory + self.initialize_territory(cohort, centroid_key, get_community_by_key) + def populate_community(self) -> None: """This function creates an instance of each functional group. @@ -208,8 +228,8 @@ def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: self.animal_cohorts[migrant.name].remove(migrant) destination.animal_cohorts[migrant.name].append(migrant) - # regenerate a territory for the cohort at the destination community - self.initialize_territory( + # Regenerate a territory for the cohort at the destination community + destination.reinitialize_territory( migrant, destination.community_key, destination.get_community_by_key, @@ -599,3 +619,7 @@ def find_intersecting_carcass_pools( ) -> MutableSequence[DecayPool]: """Default method.""" return [] + + def abandon_communities(self, consumer_cohort: Consumer) -> None: + """Default method.""" + pass diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index ba7704b93..d63c010aa 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -65,18 +65,23 @@ def update_territory(self, consumer_cohort: Consumer) -> None: self.territory_excrement = self.get_excrement_pools() self.territory_carcasses = self.get_carcass_pools() - def abandon_communities(self, cohort: Consumer) -> None: + def abandon_communities(self, consumer_cohort: Consumer) -> None: """Removes the cohort from the occupancy of every community. This method is for use in death or re-initializing territories. Args: - cohort: The cohort to be removed from the occupancy lists. + consumer_cohort: The cohort to be removed from the occupancy lists. """ for cell_id in self.grid_cell_keys: community = self.get_community_by_key(cell_id) - if cohort in community.occupancy[cohort.functional_group.name]: - del community.occupancy[cohort.functional_group.name][cohort] + if ( + consumer_cohort + in community.occupancy[consumer_cohort.functional_group.name] + ): + del community.occupancy[consumer_cohort.functional_group.name][ + consumer_cohort + ] def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: """Collect suitable prey from all grid cells in the territory. diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 4e44772ae..9d157cc0f 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -107,3 +107,7 @@ def find_intersecting_carcass_pools( ) -> MutableSequence[DecayPool]: """The find_intersecting_carcass_pools method partially defines a territory.""" ... + + def abandon_communities(self, consumer_cohort: Consumer) -> None: + """The abandon_communities method partially defines a territory.""" + ... From d6313bfabe01ca4dd035c55d0c04acf11fd251dc Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 22 Jul 2024 15:21:44 +0100 Subject: [PATCH 20/62] Updated test_animal_territories. --- tests/models/animals/conftest.py | 4 +- .../models/animals/test_animal_territories.py | 106 +++++++++++++++--- .../models/animal/animal_communities.py | 9 +- .../models/animal/animal_territories.py | 4 +- 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 9edf656a6..c1a6bf179 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -153,7 +153,7 @@ def functional_group_list_instance(shared_datadir, constants_instance): @pytest.fixture def animal_model_instance( - data_instance, + animal_data_for_community_instance, fixture_core_components, functional_group_list_instance, constants_instance, @@ -163,7 +163,7 @@ def animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel return AnimalModel( - data=data_instance, + data=animal_data_for_community_instance, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py index d12a190bd..7469a413e 100644 --- a/tests/models/animals/test_animal_territories.py +++ b/tests/models/animals/test_animal_territories.py @@ -21,32 +21,46 @@ def animal_territory_instance(self, get_community_by_key): from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory return AnimalTerritory( - grid_cell_keys=[1, 2, 3], get_community_by_key=get_community_by_key + centroid=0, + grid_cell_keys=[1, 2, 3], + get_community_by_key=get_community_by_key, ) - def test_update_territory( - self, mocker, animal_territory_instance, herbivore_cohort_instance - ): - """Test for update_territory method.""" - mock_get_prey = mocker.patch.object( - animal_territory_instance, "get_prey", return_value=[] - ) - mock_get_plant_resources = mocker.patch.object( + @pytest.fixture + def mock_get_plant_resources(self, mocker, animal_territory_instance): + """Mock get_plant_resources method.""" + return mocker.patch.object( animal_territory_instance, "get_plant_resources", return_value=[] ) - mock_get_excrement_pools = mocker.patch.object( + + @pytest.fixture + def mock_get_excrement_pools(self, mocker, animal_territory_instance): + """Mock get_excrement_pools method.""" + return mocker.patch.object( animal_territory_instance, "get_excrement_pools", return_value=[] ) - mock_get_carcass_pool = mocker.patch.object( - animal_territory_instance, "get_carcass_pool", return_value=[] + + @pytest.fixture + def mock_get_carcass_pools(self, mocker, animal_territory_instance): + """Mock get_carcass_pools method.""" + return mocker.patch.object( + animal_territory_instance, "get_carcass_pools", return_value=[] ) + def test_update_territory( + self, + animal_territory_instance, + herbivore_cohort_instance, + mock_get_plant_resources, + mock_get_excrement_pools, + mock_get_carcass_pools, + ): + """Test for update_territory method.""" animal_territory_instance.update_territory(herbivore_cohort_instance) - mock_get_prey.assert_called_once_with(herbivore_cohort_instance) mock_get_plant_resources.assert_called_once() mock_get_excrement_pools.assert_called_once() - mock_get_carcass_pool.assert_called_once() + mock_get_carcass_pools.assert_called_once() def test_get_prey( self, @@ -83,15 +97,75 @@ def test_get_excrement_pools(self, animal_territory_instance): for excrement in excrement_pools: assert isinstance(excrement, ExcrementPool) - def test_get_carcass_pool(self, animal_territory_instance): + def test_get_carcass_pools(self, animal_territory_instance): """Test for get carcass pool method.""" from virtual_ecosystem.models.animal.decay import CarcassPool - carcass_pools = animal_territory_instance.get_carcass_pool() + carcass_pools = animal_territory_instance.get_carcass_pools() assert len(carcass_pools) == len(animal_territory_instance.grid_cell_keys) for carcass in carcass_pools: assert isinstance(carcass, CarcassPool) + @pytest.fixture + def mock_carcass_pool(self, mocker): + """Fixture for a mock CarcassPool.""" + mock_pool = mocker.Mock() + mock_pool.scavengeable_energy = 10000.0 + mock_pool.decomposed_energy = 0.0 + return mock_pool + + @pytest.fixture + def mock_community(self, mocker, mock_carcass_pool): + """Fixture for a mock AnimalCommunity with a carcass pool.""" + community_mock = mocker.Mock() + community_mock.carcass_pool = mock_carcass_pool + return community_mock + + @pytest.fixture + def mock_get_community_by_key(self, mocker, mock_community): + """Fixture for get_community_by_key, returning a mock community.""" + return mocker.Mock(side_effect=lambda key: mock_community) + + @pytest.fixture + def animal_territory_instance_1(self, mock_get_community_by_key): + """Fixture for the first animal territory with mock get_community_by_key.""" + from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory + + return AnimalTerritory( + centroid=0, + grid_cell_keys=[1, 2, 3], + get_community_by_key=mock_get_community_by_key, + ) + + @pytest.fixture + def animal_territory_instance_2(self, mock_get_community_by_key): + """Fixture for the second animal territory with mock get_community_by_key.""" + from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory + + return AnimalTerritory( + centroid=1, + grid_cell_keys=[2, 3, 4], + get_community_by_key=mock_get_community_by_key, + ) + + def test_find_intersecting_carcass_pools( + self, + animal_territory_instance_1, + animal_territory_instance_2, + mock_carcass_pool, + ): + """Test for find_intersecting_carcass_pools method.""" + intersecting_pools = ( + animal_territory_instance_1.find_intersecting_carcass_pools( + animal_territory_instance_2 + ) + ) + + # Since the same mock object is returned, we need to repeat it for the + # expected value. + expected_pools = [mock_carcass_pool, mock_carcass_pool] + assert intersecting_pools == expected_pools + @pytest.mark.parametrize( "centroid_key, target_cell_number, cell_nx, cell_ny, expected", diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 2e657c240..f98449bb8 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -213,8 +213,9 @@ def populate_community(self) -> None: def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: """Function to move an AnimalCohort between AnimalCommunity objects. - This function should take a cohort and a destination community and then pop the - cohort from this community to the destination. + This function takes a cohort and a destination community, changes the + centroid of the cohort's territory to be the new community, and then + reinitializes the territory around the new centroid. TODO: travel distance should be a function of body-size or locomotion once multi-grid occupancy is integrated. @@ -236,7 +237,7 @@ def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: ) def migrate_community(self) -> None: - """This handles migrating all cohorts in a community. + """This handles migrating all cohorts with a centroid in the community. This migration method initiates migration for two reasons: 1) The cohort is starving and needs to move for a chance at resource access @@ -399,6 +400,8 @@ def collect_prey( This is a helper function for territory.get_prey, it filters suitable prey from the total list of animal cohorts across the territory. + TODO: possibly moved to be a territory method + Args: consumer_cohort: The AnimalCohort for which a prey list is being collected diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index d63c010aa..766d27a6f 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -43,7 +43,7 @@ def __init__( """A list of grid cells present in the territory.""" self.get_community_by_key = get_community_by_key """A list of animal communities present in the territory.""" - self.territory_prey: Sequence[Consumer] = [] + # self.territory_prey: Sequence[Consumer] = [] """A list of animal prey present in the territory.""" self.territory_plants: Sequence[Resource] = [] """A list of plant resources present in the territory.""" @@ -60,7 +60,7 @@ def update_territory(self, consumer_cohort: Consumer) -> None: """ - self.territory_prey = self.get_prey(consumer_cohort) + # self.territory_prey = self.get_prey(consumer_cohort) self.territory_plants = self.get_plant_resources() self.territory_excrement = self.get_excrement_pools() self.territory_carcasses = self.get_carcass_pools() From 679899716e9126cb7294d0b1119693347ff05807 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 23 Jul 2024 12:42:07 +0100 Subject: [PATCH 21/62] Updated tests for animal_cohorts. --- tests/models/animals/conftest.py | 53 ++- tests/models/animals/test_animal_cohorts.py | 389 ++++++++++++++---- .../models/animals/test_animal_territories.py | 20 - 3 files changed, 356 insertions(+), 106 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index c1a6bf179..a784e0986 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -204,12 +204,19 @@ def herbivore_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def herbivore_cohort_instance(herbivore_functional_group_instance, constants_instance): +def herbivore_cohort_instance( + herbivore_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - herbivore_functional_group_instance, 10000.0, 1, 10, constants_instance + herbivore_functional_group_instance, + 10000.0, + 1, + 10, + animal_territory_instance, + constants_instance, ) @@ -228,13 +235,18 @@ def caterpillar_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def caterpillar_cohort_instance( - caterpillar_functional_group_instance, constants_instance + caterpillar_functional_group_instance, animal_territory_instance, constants_instance ): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - caterpillar_functional_group_instance, 1.0, 1, 100, constants_instance + caterpillar_functional_group_instance, + 1.0, + 1, + 100, + animal_territory_instance, + constants_instance, ) @@ -293,13 +305,42 @@ def plant_list_instance(plant_data_instance, constants_instance): @pytest.fixture -def animal_list_instance(herbivore_functional_group_instance, constants_instance): +def animal_list_instance( + herbivore_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture providing a list of animal cohorts.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return [ AnimalCohort( - herbivore_functional_group_instance, 10000.0, 1, 10, constants_instance + herbivore_functional_group_instance, + 10000.0, + 1, + 10, + animal_territory_instance, + constants_instance, ) for idx in range(3) ] + + +@pytest.fixture +def get_community_by_key(animal_community_instance): + """Fixture for get_community_by_key.""" + + def _get_community_by_key(key): + return animal_community_instance + + return _get_community_by_key + + +@pytest.fixture +def animal_territory_instance(get_community_by_key): + """Fixture for animal territories.""" + from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory + + return AnimalTerritory( + centroid=0, + grid_cell_keys=[1, 2, 3], + get_community_by_key=get_community_by_key, + ) diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index ea4ab6130..11e20e06e 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -18,12 +18,19 @@ def predator_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def predator_cohort_instance(predator_functional_group_instance, constants_instance): +def predator_cohort_instance( + predator_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - predator_functional_group_instance, 10000.0, 1, 10, constants_instance + predator_functional_group_instance, + 10000.0, + 1, + 10, + animal_territory_instance, + constants_instance, ) @@ -41,22 +48,36 @@ def ectotherm_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def ectotherm_cohort_instance(ectotherm_functional_group_instance, constants_instance): +def ectotherm_cohort_instance( + ectotherm_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - ectotherm_functional_group_instance, 100.0, 1, 10, constants_instance + ectotherm_functional_group_instance, + 100.0, + 1, + 10, + animal_territory_instance, + constants_instance, ) @pytest.fixture -def prey_cohort_instance(herbivore_functional_group_instance, constants_instance): +def prey_cohort_instance( + herbivore_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - herbivore_functional_group_instance, 100.0, 1, 10, constants_instance + herbivore_functional_group_instance, + 100.0, + 1, + 10, + animal_territory_instance, + constants_instance, ) @@ -92,6 +113,7 @@ def test_invalid_animal_cohort_initialization( age, individuals, error_type, + animal_territory_instance, constants_instance, ): """Test for invalid inputs during AnimalCohort initialization.""" @@ -103,6 +125,7 @@ def test_invalid_animal_cohort_initialization( mass, age, individuals, + animal_territory_instance, constants_instance, ) @@ -237,18 +260,75 @@ def test_metabolize( assert isclose(cohort_instance.mass_current, expected_final_mass, rtol=1e-9) @pytest.mark.parametrize( - "cohort_type, excreta_mass, initial_pool_energy, expected_pool_energy", + "cohort_type, excreta_mass, initial_pool_energy, num_pools," + "expected_pool_energy", [ - ("herbivore", 100.0, 500.0, 500.0), # normal case for herbivore - ("herbivore", 0.0, 500.0, 500.0), # zero excreta mass for herbivore - ("ectotherm", 50.0, 300.0, 300.0), # normal case for ectotherm - ("ectotherm", 0.0, 300.0, 300.0), # zero excreta mass for ectotherm + ( + "herbivore", + 100.0, + 500.0, + 1, + 500.0, + ), # normal case for herbivore with one pool + ( + "herbivore", + 0.0, + 500.0, + 1, + 500.0, + ), # zero excreta mass for herbivore with one pool + ( + "ectotherm", + 50.0, + 300.0, + 1, + 300.0, + ), # normal case for ectotherm with one pool + ( + "ectotherm", + 0.0, + 300.0, + 1, + 300.0, + ), # zero excreta mass for ectotherm with one pool + ( + "herbivore", + 100.0, + 500.0, + 3, + 500.0, + ), # normal case for herbivore with multiple pools + ( + "herbivore", + 0.0, + 500.0, + 3, + 500.0, + ), # zero excreta mass for herbivore with multiple pools + ( + "ectotherm", + 50.0, + 300.0, + 3, + 300.0, + ), # normal case for ectotherm with multiple pools + ( + "ectotherm", + 0.0, + 300.0, + 3, + 300.0, + ), # zero excreta mass for ectotherm with multiple pools ], ids=[ - "herbivore_normal", - "herbivore_zero_excreta", - "ectotherm_normal", - "ectotherm_zero_excreta", + "herbivore_normal_one_pool", + "herbivore_zero_excreta_one_pool", + "ectotherm_normal_one_pool", + "ectotherm_zero_excreta_one_pool", + "herbivore_normal_multiple_pools", + "herbivore_zero_excreta_multiple_pools", + "ectotherm_normal_multiple_pools", + "ectotherm_zero_excreta_multiple_pools", ], ) def test_excrete( @@ -259,13 +339,10 @@ def test_excrete( cohort_type, excreta_mass, initial_pool_energy, + num_pools, expected_pool_energy, ): - """Testing excrete method for various scenarios. - - This method is doing nothing of substance until the stoichiometry rework. - - """ + """Testing excrete method for various scenarios.""" # Select the appropriate cohort instance if cohort_type == "herbivore": @@ -275,15 +352,39 @@ def test_excrete( else: raise ValueError("Invalid cohort type provided.") - # Mock the excrement pool - excrement_pool = mocker.Mock() - excrement_pool.decomposed_energy = initial_pool_energy + # Mock the excrement pools + excrement_pools = [] + for _ in range(num_pools): + excrement_pool = mocker.Mock() + excrement_pool.decomposed_energy = initial_pool_energy + excrement_pool.scavengeable_energy = initial_pool_energy + excrement_pools.append(excrement_pool) # Call the excrete method - cohort_instance.excrete(excreta_mass, excrement_pool) + cohort_instance.excrete(excreta_mass, excrement_pools) # Check the expected results - assert excrement_pool.decomposed_energy == expected_pool_energy + for excrement_pool in excrement_pools: + expected_decomposed_energy = ( + initial_pool_energy + + cohort_instance.decay_fraction_excrement + * excreta_mass + / num_pools + * cohort_instance.constants.nitrogen_excreta_proportion + ) + expected_scavengeable_energy = ( + initial_pool_energy + + (1 - cohort_instance.decay_fraction_excrement) + * excreta_mass + / num_pools + * cohort_instance.constants.nitrogen_excreta_proportion + ) + assert excrement_pool.decomposed_energy == pytest.approx( + expected_decomposed_energy + ) + assert excrement_pool.scavengeable_energy == pytest.approx( + expected_scavengeable_energy + ) @pytest.mark.parametrize( "cohort_type, excreta_mass, expected_carbon_waste", @@ -329,30 +430,73 @@ def test_respire( assert carbon_waste == expected_carbon_waste @pytest.mark.parametrize( - "scav_initial, scav_final, decomp_initial, decomp_final, consumed_energy", + "scav_initial, scav_final, decomp_initial, decomp_final, consumed_energy," + "num_pools", [ - (1000.0, 1500.0, 0.0, 500.0, 1000.0), - (0.0, 500.0, 1000.0, 1500.0, 1000.0), - (1000.0, 1000.0, 0.0, 0.0, 0.0), - (0.0, 0.0, 1000.0, 1000.0, 0.0), + (1000.0, 1500.0, 0.0, 500.0, 1000.0, 1), + (0.0, 500.0, 1000.0, 1500.0, 1000.0, 1), + (1000.0, 1000.0, 0.0, 0.0, 0.0, 1), + (0.0, 0.0, 1000.0, 1000.0, 0.0, 1), + (1000.0, 1166.67, 0.0, 166.67, 1000.0, 3), # Test with multiple pools + (0.0, 166.67, 1000.0, 1166.67, 1000.0, 3), # Test with multiple pools + ], + ids=[ + "single_pool_scenario_1", + "single_pool_scenario_2", + "single_pool_scenario_3", + "single_pool_scenario_4", + "multiple_pools_scenario_1", + "multiple_pools_scenario_2", ], ) def test_defecate( self, + mocker, herbivore_cohort_instance, - excrement_pool_instance, scav_initial, scav_final, decomp_initial, decomp_final, consumed_energy, + num_pools, ): - """Testing defecate() for varying soil energy levels.""" - excrement_pool_instance.scavengeable_energy = scav_initial - excrement_pool_instance.decomposed_energy = decomp_initial - herbivore_cohort_instance.defecate(excrement_pool_instance, consumed_energy) - assert excrement_pool_instance.scavengeable_energy == scav_final - assert excrement_pool_instance.decomposed_energy == decomp_final + """Testing defecate() for varying soil energy levels and multiple pools.""" + + # Mock the excrement pools + excrement_pools = [] + for _ in range(num_pools): + excrement_pool = mocker.Mock() + excrement_pool.scavengeable_energy = scav_initial + excrement_pool.decomposed_energy = decomp_initial + excrement_pools.append(excrement_pool) + + # Call the defecate method + herbivore_cohort_instance.defecate(excrement_pools, consumed_energy) + + # Check the expected results + for excrement_pool in excrement_pools: + expected_scavengeable_energy = ( + scav_initial + + (1 - herbivore_cohort_instance.decay_fraction_excrement) + * consumed_energy + / num_pools + * herbivore_cohort_instance.functional_group.conversion_efficiency + * herbivore_cohort_instance.individuals + ) + expected_decomposed_energy = ( + decomp_initial + + herbivore_cohort_instance.decay_fraction_excrement + * consumed_energy + / num_pools + * herbivore_cohort_instance.functional_group.conversion_efficiency + * herbivore_cohort_instance.individuals + ) + assert excrement_pool.scavengeable_energy == pytest.approx( + expected_scavengeable_energy + ) + assert excrement_pool.decomposed_energy == pytest.approx( + expected_decomposed_energy + ) @pytest.mark.parametrize( "dt, initial_age, final_age", @@ -377,66 +521,144 @@ def test_increase_age(self, herbivore_cohort_instance, dt, initial_age, final_ag "initial_carcass", "final_carcass", "decomp_carcass", + "num_pools", ], argvalues=[ - (0, 0, 0, 0.0, 0.0, 0.0), - (0, 1000, 1000, 0.0, 0.0, 0.0), - (1, 1, 0, 1.0, 8001.0, 2000.0), - (100, 200, 100, 0.0, 800000.0, 200000.0), + (0, 0, 0, 0.0, 0.0, 0.0, 1), + (0, 1000, 1000, 0.0, 0.0, 0.0, 1), + (1, 1, 0, 1.0, 8001.0, 2001.0, 1), + (100, 200, 100, 0.0, 800000.0, 200000.0, 1), + (1, 1, 0, 1.0, 8001.0, 667.6666666, 3), + (100, 200, 100, 0.0, 800000.0, 66666.66666666, 3), + ], + ids=[ + "zero_death_empty_pop", + "zero_death_non_empty_pop", + "single_death_single_pool", + "multiple_deaths_single_pool", + "single_death_multiple_pools", + "multiple_deaths_multiple_pools", ], ) def test_die_individual( self, + mocker, herbivore_cohort_instance, number_dead, initial_pop, final_pop, - carcass_pool_instance, initial_carcass, final_carcass, decomp_carcass, + num_pools, ): """Testing death.""" + herbivore_cohort_instance.individuals = initial_pop - carcass_pool_instance.scavengeable_energy = initial_carcass - herbivore_cohort_instance.die_individual(number_dead, carcass_pool_instance) + + # Mock the carcass pools + carcass_pools = [] + for _ in range(num_pools): + carcass_pool = mocker.Mock() + carcass_pool.scavengeable_energy = initial_carcass + carcass_pool.decomposed_energy = initial_carcass + carcass_pools.append(carcass_pool) + + herbivore_cohort_instance.die_individual(number_dead, carcass_pools) + assert herbivore_cohort_instance.individuals == final_pop - assert carcass_pool_instance.scavengeable_energy == final_carcass - assert carcass_pool_instance.decomposed_energy == decomp_carcass + for carcass_pool in carcass_pools: + expected_scavengeable_energy = ( + initial_carcass + + (1 - herbivore_cohort_instance.decay_fraction_carcasses) + * (number_dead * herbivore_cohort_instance.mass_current) + / num_pools + ) + + assert carcass_pool.scavengeable_energy == pytest.approx( + expected_scavengeable_energy + ) + assert carcass_pool.decomposed_energy == pytest.approx(decomp_carcass) + @pytest.mark.parametrize( + "initial_individuals, potential_consumed_mass, mechanical_efficiency," + "expected_consumed_mass", + [ + (10, 100.0, 0.75, 100.0), + (5, 200.0, 0.5, 200.0), + (100, 50.0, 0.8, 50.0), + (1, 5.0, 0.9, 5.0), + ], + ids=[ + "ten_individuals_consumed_100_mass_eff_0.75", + "five_individuals_consumed_200_mass_eff_0.5", + "hundred_individuals_consumed_50_mass_eff_0.8", + "one_individual_consumed_5_mass_eff_0.9", + ], + ) def test_get_eaten( - self, prey_cohort_instance, predator_cohort_instance, carcass_pool_instance + self, + mocker, + herbivore_cohort_instance, + predator_cohort_instance, + initial_individuals, + potential_consumed_mass, + mechanical_efficiency, + expected_consumed_mass, ): """Test the get_eaten method for accuracy in updating prey and carcass pool.""" - potential_consumed_mass = 100 # Set a potential consumed mass for testing - initial_individuals = prey_cohort_instance.individuals - initial_mass_current = prey_cohort_instance.mass_current - initial_carcass_scavengeable_energy = carcass_pool_instance.scavengeable_energy - initial_carcass_decomposed_energy = carcass_pool_instance.decomposed_energy + + from math import ceil + + # Setup initial values + herbivore_cohort_instance.individuals = initial_individuals + predator_cohort_instance.functional_group.mechanical_efficiency = ( + mechanical_efficiency + ) + + # Mock find_intersecting_carcass_pools to return a list of mock carcass pools + carcass_pool_1 = mocker.Mock() + carcass_pool_2 = mocker.Mock() + mock_find_intersecting_carcass_pools = mocker.patch.object( + herbivore_cohort_instance.territory, + "find_intersecting_carcass_pools", + return_value=[carcass_pool_1, carcass_pool_2], + ) + + # Mock update_carcass_pool to update the carcass pools + mock_update_carcass_pool = mocker.patch.object( + herbivore_cohort_instance, "update_carcass_pool" + ) # Execute the get_eaten method with test parameters - actual_consumed_mass = prey_cohort_instance.get_eaten( - potential_consumed_mass, predator_cohort_instance, carcass_pool_instance + actual_consumed_mass = herbivore_cohort_instance.get_eaten( + potential_consumed_mass, predator_cohort_instance ) - # Assertions to check if individuals were correctly removed and carcass pool - # updated - assert ( - prey_cohort_instance.individuals < initial_individuals - ), "Prey cohort should have fewer individuals." - assert ( - prey_cohort_instance.mass_current == initial_mass_current - ), "Prey cohort should have the same total mass." - assert ( - actual_consumed_mass <= potential_consumed_mass - ), "Actual consumed mass should be less than/equal to potential consumed mass." - assert ( - carcass_pool_instance.scavengeable_energy - > initial_carcass_scavengeable_energy - ), "Carcass pool's scavengeable energy should increase." - assert ( - carcass_pool_instance.decomposed_energy > initial_carcass_decomposed_energy - ), "Carcass pool's decomposed energy should increase." + # Calculate expected individuals killed + individual_mass = herbivore_cohort_instance.mass_current + max_individuals_killed = ceil(potential_consumed_mass / individual_mass) + actual_individuals_killed = min(max_individuals_killed, initial_individuals) + expected_final_individuals = initial_individuals - actual_individuals_killed + + # Assertions for if individuals were correctly removed and carcass pool updated + assert herbivore_cohort_instance.individuals == expected_final_individuals + assert actual_consumed_mass == pytest.approx(expected_consumed_mass) + + # Verify the update_carcass_pool call + carcass_mass = ( + (actual_individuals_killed * individual_mass) + - actual_consumed_mass + + (actual_consumed_mass * (1 - mechanical_efficiency)) + ) + mock_update_carcass_pool.assert_called_once_with( + carcass_mass, [carcass_pool_1, carcass_pool_2] + ) + + # Check if find_intersecting_carcass_pools was called correctly + mock_find_intersecting_carcass_pools.assert_called_once_with( + predator_cohort_instance.territory + ) @pytest.mark.parametrize( "below_threshold,expected_mass_current_increase," @@ -549,6 +771,7 @@ def test_calculate_alpha( mass_current, expected_alpha, herbivore_functional_group_instance, + animal_territory_instance, ): """Testing for calculate alpha.""" # Assuming necessary imports and setup based on previous examples @@ -571,6 +794,7 @@ def test_calculate_alpha( mass=mass_current, age=1.0, # Example age individuals=1, # Example number of individuals + territory=animal_territory_instance, constants=constants, ) @@ -592,7 +816,13 @@ def test_calculate_alpha( ], ) def test_calculate_potential_consumed_biomass( - self, mocker, alpha, mass_current, phi_herb_t, expected_biomass + self, + mocker, + alpha, + mass_current, + phi_herb_t, + expected_biomass, + animal_territory_instance, ): """Testing for calculate_potential_consumed_biomass.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort @@ -625,6 +855,7 @@ def test_calculate_potential_consumed_biomass( mass=100.0, # Arbitrary value since mass is not directly used in this test age=1.0, # Arbitrary value individuals=1, # Arbitrary value + territory=animal_territory_instance, constants=mocker.MagicMock(), ) @@ -971,7 +1202,6 @@ def test_delta_mass_predation( predator_cohort_instance, animal_list_instance, excrement_pool_instance, - carcass_pool_instance, consumed_mass, expected_total_consumed_mass, ): @@ -998,7 +1228,7 @@ def test_delta_mass_predation( mock_defecate = mocker.patch.object(predator_cohort_instance, "defecate") total_consumed_mass = predator_cohort_instance.delta_mass_predation( - animal_list_instance, excrement_pool_instance, carcass_pool_instance + animal_list_instance, excrement_pool_instance ) # Check if the total consumed mass matches the expected value @@ -1060,7 +1290,6 @@ def test_forage_cohort( plant_list_instance, animal_list_instance, excrement_pool_instance, - carcass_pool_instance, ): """Test foraging behavior for different diet types.""" @@ -1076,7 +1305,7 @@ def test_forage_cohort( # Test herbivore diet herbivore_cohort_instance.forage_cohort( - plant_list_instance, [], excrement_pool_instance, carcass_pool_instance + plant_list_instance, [], excrement_pool_instance ) mock_delta_mass_herbivory.assert_called_once_with( plant_list_instance, excrement_pool_instance @@ -1085,10 +1314,10 @@ def test_forage_cohort( # Test carnivore diet predator_cohort_instance.forage_cohort( - [], animal_list_instance, excrement_pool_instance, carcass_pool_instance + [], animal_list_instance, excrement_pool_instance ) mock_delta_mass_predation.assert_called_once_with( - animal_list_instance, excrement_pool_instance, carcass_pool_instance + animal_list_instance, excrement_pool_instance ) mock_eat_predator.assert_called_once_with(200) @@ -1234,7 +1463,7 @@ def test_inflict_non_predation_mortality( print(f"Initial individuals: {cohort.individuals}") # Run the method - cohort.inflict_non_predation_mortality(dt, carcass_pool_instance) + cohort.inflict_non_predation_mortality(dt, [carcass_pool_instance]) # Calculate expected number of deaths inside the test u_bg_value = sf.background_mortality(u_bg) diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py index 7469a413e..cfac01f6a 100644 --- a/tests/models/animals/test_animal_territories.py +++ b/tests/models/animals/test_animal_territories.py @@ -6,26 +6,6 @@ class TestAnimalTerritories: """For testing the AnimalTerritories class.""" - @pytest.fixture - def get_community_by_key(self, animal_community_instance): - """Fixture for get_community_by_key.""" - - def _get_community_by_key(key): - return animal_community_instance - - return _get_community_by_key - - @pytest.fixture - def animal_territory_instance(self, get_community_by_key): - """Fixture for animal territories.""" - from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory - - return AnimalTerritory( - centroid=0, - grid_cell_keys=[1, 2, 3], - get_community_by_key=get_community_by_key, - ) - @pytest.fixture def mock_get_plant_resources(self, mocker, animal_territory_instance): """Mock get_plant_resources method.""" From 1eba4f266b84141258fca7eb6cc299abe84da581 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 24 Jul 2024 11:37:53 +0100 Subject: [PATCH 22/62] Added all_occupying_cohorts method and revised test_forage_community. --- tests/models/animals/conftest.py | 11 +- .../models/animals/test_animal_communities.py | 127 +++++++----------- .../models/animal/animal_communities.py | 21 ++- 3 files changed, 80 insertions(+), 79 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index a784e0986..b0760bd90 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -264,12 +264,19 @@ def butterfly_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def butterfly_cohort_instance(butterfly_functional_group_instance, constants_instance): +def butterfly_cohort_instance( + butterfly_functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return AnimalCohort( - butterfly_functional_group_instance, 1.0, 1, 100, constants_instance + butterfly_functional_group_instance, + 1.0, + 1, + 100, + animal_territory_instance, + constants_instance, ) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py index 29f17259b..6a43b80a6 100644 --- a/tests/models/animals/test_animal_communities.py +++ b/tests/models/animals/test_animal_communities.py @@ -19,8 +19,8 @@ def animal_community_destination_instance( return AnimalCommunity( functional_groups=functional_group_list_instance, data=animal_data_for_community_instance, - community_key=4, - neighbouring_keys=[1, 3, 5, 7], + community_key=5, + neighbouring_keys=[2, 8, 4, 6], get_community_by_key=animal_model_instance.get_community_by_key, constants=constants_instance, ) @@ -40,7 +40,9 @@ def functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def animal_cohort_instance(functional_group_instance, constants_instance): +def animal_cohort_instance( + functional_group_instance, animal_territory_instance, constants_instance +): """Fixture for an animal cohort used in tests.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort @@ -49,6 +51,7 @@ def animal_cohort_instance(functional_group_instance, constants_instance): functional_group_instance.adult_mass, 1.0, 10, + animal_territory_instance, constants_instance, ) @@ -91,17 +94,17 @@ def test_all_animal_cohorts_property( self, animal_community_instance, animal_cohort_instance ): """Test the all_animal_cohorts property.""" - from collections.abc import Iterable # Add an animal cohort to the community animal_community_instance.animal_cohorts["herbivorous_mammal"].append( animal_cohort_instance ) + animal_community_instance.occupancy["herbivorous_mammal"][ + animal_cohort_instance + ] = 1.0 # Check if the added cohort is in the all_animal_cohorts property assert animal_cohort_instance in animal_community_instance.all_animal_cohorts - # Check the type of all_animal_cohorts - assert isinstance(animal_community_instance.all_animal_cohorts, Iterable) def test_populate_community(self, animal_community_instance): """Testing populate_community.""" @@ -194,15 +197,16 @@ def test_migrate_community( cohort.age = age cohort.mass_current = cohort.functional_group.adult_mass * mass_ratio - # Mock the get_community_by_key callable to return a specific community. + # Mock the get_community_by_key method to return the destination community. mocker.patch.object( animal_community_instance, "get_community_by_key", return_value=animal_community_destination_instance, ) - # Append cohort to the source community - animal_community_instance.animal_cohorts["herbivorous_mammal"].append(cohort) + # Append cohort to the source community based on the functional group name + functional_group_name = cohort.functional_group.name + animal_community_instance.animal_cohorts[functional_group_name].append(cohort) # Mock `migrate_juvenile_probability` to control juvenile migration logic mocker.patch.object( @@ -215,44 +219,16 @@ def test_migrate_community( # Check migration outcome based on expected results if should_migrate: assert ( - cohort - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) + cohort not in animal_community_instance.animal_cohorts[cohort.name] + ), f"Cohort {cohort} should have migrated but didn't." assert ( cohort - in animal_community_destination_instance.animal_cohorts[ - "herbivorous_mammal" - ] - ) + in animal_community_destination_instance.animal_cohorts[cohort.name] + ), f"Cohort {cohort} should be in the destination community but isn't." else: assert ( - cohort in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - - def test_remove_dead_cohort( - self, animal_cohort_instance, animal_community_instance - ): - """Testing remove_dead_cohort.""" - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - assert animal_cohort_instance.is_alive - animal_community_instance.remove_dead_cohort(animal_cohort_instance) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - animal_cohort_instance.is_alive = False - assert not animal_cohort_instance.is_alive - animal_community_instance.remove_dead_cohort(animal_cohort_instance) - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) + cohort in animal_community_instance.animal_cohorts[cohort.name] + ), f"Cohort {cohort} should have stayed but migrated." @pytest.mark.parametrize( "reproductive_type, initial_mass, expected_offspring", @@ -375,48 +351,49 @@ def test_birth_community(self, animal_community_instance, constants_instance): assert new_count_above_threshold == initial_count_above_threshold + 1 def test_forage_community( - self, animal_cohort_instance, animal_community_instance, mocker + self, + mocker, + animal_community_instance, + animal_cohort_instance, + animal_territory_instance, ): - """Testing forage_community.""" - import unittest - from copy import deepcopy + """Test foraging of animal cohorts in a community.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity + cohort = animal_cohort_instance + cohort.territory = animal_territory_instance - # Prepare data - animal_cohort_instance_2 = deepcopy(animal_cohort_instance) - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance + # Mock the necessary territory methods to return appropriate resources + get_prey_mock = mocker.patch.object( + animal_territory_instance, "get_prey", return_value=[] ) - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance_2 + get_plant_resources_mock = mocker.patch.object( + animal_territory_instance, "get_plant_resources", return_value=[] + ) + get_excrement_pools_mock = mocker.patch.object( + animal_territory_instance, "get_excrement_pools", return_value=[] ) - # Mock methods - mock_forage_cohort = mocker.patch.object(AnimalCohort, "forage_cohort") - - mock_collect_prey = mocker.patch.object( - AnimalCommunity, "collect_prey", return_value=mocker.MagicMock() + # Mock the forage_cohort method to simulate foraging + forage_cohort_mock = mocker.patch.object( + cohort, "forage_cohort", return_value=None ) - # Execute method + # Append cohort to the source community based on the functional group name + functional_group_name = cohort.functional_group.name + if functional_group_name not in animal_community_instance.animal_cohorts: + animal_community_instance.animal_cohorts[functional_group_name] = [] + animal_community_instance.animal_cohorts[functional_group_name].append(cohort) + + # Perform the foraging animal_community_instance.forage_community() - # Check if the forage_cohort and collect_prey methods have been called for each - # cohort - assert mock_forage_cohort.call_count == 2 - assert mock_collect_prey.call_count == 2 - - # Check if the forage_cohort and collect_prey methods were called correctly - for call in mock_forage_cohort.call_args_list: - _, kwargs = call - assert isinstance(kwargs.get("plant_list", None), list) - assert isinstance(kwargs.get("animal_list", None), unittest.mock.MagicMock) - assert isinstance( - kwargs.get("carcass_pool", None), - type(animal_community_instance.carcass_pool), - ) + # Check if the helper methods were called correctly + get_prey_mock.assert_called_with(cohort) + get_plant_resources_mock.assert_called_once() + get_excrement_pools_mock.assert_called_once() + forage_cohort_mock.assert_called_with( + plant_list=[], animal_list=[], excrement_pools=[] + ) def test_collect_prey_finds_eligible_prey( self, diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index f98449bb8..e62e520fd 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -95,6 +95,18 @@ def all_animal_cohorts(self) -> Iterable[AnimalCohort]: This property provides access to all the animal cohorts contained within this community class. + Returns: + Iterable[AnimalCohort]: An iterable of AnimalCohort objects. + """ + return chain.from_iterable(self.animal_cohorts.values()) + + @property + def all_occupying_cohorts(self) -> Iterable[AnimalCohort]: + """Get an iterable of all occupying cohorts w/ proportion in the community. + + This property provides access to all the animal cohorts contained + within this community class. + Returns: Iterable[AnimalCohort]: An iterable of AnimalCohort objects. """ @@ -111,6 +123,7 @@ def initialize_territory( """This initializes the territory occupied by the cohort. TODO: update the territory size to cell number conversion using grid size + TODO: needs test Args: cohort: The animal cohort occupying the territory. @@ -158,6 +171,7 @@ def reinitialize_territory( """This initializes the territory occupied by the cohort. TODO: update the territory size to cell number conversion using grid size + TODO: needs test Args: cohort: The animal cohort occupying the territory. @@ -245,14 +259,17 @@ def migrate_community(self) -> None: TODO: MGO - migrate distance mod for larger territories? + """ for cohort in self.all_animal_cohorts: - migrate = cohort.is_below_mass_threshold( + is_starving = cohort.is_below_mass_threshold( self.constants.dispersal_mass_threshold - ) or ( + ) + is_juvenile_and_migrate = ( cohort.age == 0.0 and random.random() <= cohort.migrate_juvenile_probability() ) + migrate = is_starving or is_juvenile_and_migrate if not migrate: continue From 1f851903c252fb161a10e5e12ee79fa73870fabd Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 25 Jul 2024 11:33:26 +0100 Subject: [PATCH 23/62] Revised test_inflict_non_predation_mortality. --- tests/models/animals/conftest.py | 8 ++++ .../models/animals/test_animal_communities.py | 41 +++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index b0760bd90..f8b4ff44b 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -351,3 +351,11 @@ def animal_territory_instance(get_community_by_key): grid_cell_keys=[1, 2, 3], get_community_by_key=get_community_by_key, ) + + +@pytest.fixture +def carcass_pool_instance(): + """Fixture for an carcass pool used in tests.""" + from virtual_ecosystem.models.animal.decay import CarcassPool + + return CarcassPool(0.0, 0.0) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py index 6a43b80a6..6522363a4 100644 --- a/tests/models/animals/test_animal_communities.py +++ b/tests/models/animals/test_animal_communities.py @@ -400,11 +400,20 @@ def test_collect_prey_finds_eligible_prey( animal_cohort_instance, animal_community_instance, functional_group_instance, + animal_territory_instance, + constants_instance, ): """Testing collect_prey with eligible prey items.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - prey_cohort = AnimalCohort(functional_group_instance, 5000.0, 1, 10) + prey_cohort = AnimalCohort( + functional_group_instance, + 5000.0, + 1, + 10, + animal_territory_instance, + constants_instance, + ) animal_community_instance.animal_cohorts[functional_group_instance.name].append( prey_cohort ) @@ -422,11 +431,20 @@ def test_collect_prey_filters_out_ineligible_prey( animal_cohort_instance, animal_community_instance, functional_group_instance, + animal_territory_instance, + constants_instance, ): """Testing collect_prey with no eligible prey items.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - prey_cohort = AnimalCohort(functional_group_instance, 20000.0, 1, 10) + prey_cohort = AnimalCohort( + functional_group_instance, + 20000.0, + 1, + 10, + animal_territory_instance, + constants_instance, + ) animal_community_instance.animal_cohorts[functional_group_instance.name].append( prey_cohort ) @@ -515,7 +533,11 @@ def test_metabolize_community( ], ) def test_inflict_non_predation_mortality_community( - self, mocker, animal_community_instance, days + self, + mocker, + animal_community_instance, + carcass_pool_instance, + days, ): """Testing natural mortality infliction for the entire community.""" from numpy import timedelta64 @@ -530,6 +552,11 @@ def test_inflict_non_predation_mortality_community( "inflict_non_predation_mortality" ) + # Ensure the territory carcasses is correctly set to the carcass pool instance + for functional_group in animal_community_instance.animal_cohorts.values(): + for cohort in functional_group: + cohort.territory.territory_carcasses = carcass_pool_instance + # Call the community method to inflict natural mortality animal_community_instance.inflict_non_predation_mortality_community(dt) @@ -538,9 +565,7 @@ def test_inflict_non_predation_mortality_community( # Assert the inflict_non_predation_mortality method was called for each cohort for cohorts in animal_community_instance.animal_cohorts.values(): for cohort in cohorts: - mock_mortality.assert_called_with( - number_of_days, animal_community_instance.carcass_pool - ) + mock_mortality.assert_called_with(number_of_days, carcass_pool_instance) # Check if cohorts with no individuals left are flagged as not alive for cohorts in animal_community_instance.animal_cohorts.values(): @@ -551,7 +576,9 @@ def test_inflict_non_predation_mortality_community( ), "Cohort with no individuals should be marked as not alive" assert ( cohort - not in animal_community_instance.animal_cohorts[cohort.name] + not in animal_community_instance.animal_cohorts[ + cohort.functional_group.name + ] ), "Dead cohort should be removed from the community" def test_metamorphose( From af6bb16f896bcd220ef9da3898b37fe097b6d2f8 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 25 Jul 2024 12:30:59 +0100 Subject: [PATCH 24/62] Fully revised test_animal_communities for multi-grid. --- .../models/animals/test_animal_communities.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py index 6522363a4..3e5ed139d 100644 --- a/tests/models/animals/test_animal_communities.py +++ b/tests/models/animals/test_animal_communities.py @@ -585,13 +585,15 @@ def test_metamorphose( self, mocker, animal_community_instance, - caterpillar_cohort_instance, + animal_cohort_instance, butterfly_cohort_instance, + carcass_pool_instance, ): """Test the metamorphose method for different scenarios.""" - larval_cohort = caterpillar_cohort_instance + larval_cohort = animal_cohort_instance larval_cohort.is_alive = True + larval_cohort.territory.territory_carcasses = [carcass_pool_instance] adult_functional_group = butterfly_cohort_instance.functional_group adult_functional_group.birth_mass = 5.0 @@ -649,6 +651,7 @@ def test_metamorphose_community( self, animal_community_instance, caterpillar_cohort_instance, + carcass_pool_instance, mass_current, expected_caterpillar_count, expected_butterfly_count, @@ -668,6 +671,11 @@ def test_metamorphose_community( "butterfly": [], } + # Ensure the territory carcasses is correctly set to the carcass pool instance + for functional_group in animal_community_instance.animal_cohorts.values(): + for cohort in functional_group: + cohort.territory.territory_carcasses = [carcass_pool_instance] + # Initial counts initial_caterpillar_count = len( animal_community_instance.animal_cohorts.get("caterpillar", []) @@ -695,6 +703,13 @@ def test_metamorphose_community( assert new_butterfly_count == expected_butterfly_count assert caterpillar_cohort.is_alive == expected_is_alive + @pytest.fixture + def mock_bfs_territory(self, mocker): + """Fixture for mocking bfs_territory.""" + return mocker.patch( + "virtual_ecosystem.models.animal.animal_territories.bfs_territory" + ) + def test_initialize_territory( self, mocker, @@ -705,19 +720,26 @@ def test_initialize_territory( """Test for initialize territory.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.functional_group import FunctionalGroup # Create mock instances for dependencies + mock_functional_group = mocker.create_autospec(FunctionalGroup, instance=True) + mock_functional_group.name = "herbivorous_mammal" + mock_cohort = mocker.create_autospec(AnimalCohort, instance=True) mock_cohort.territory_size = 4 # Example size + mock_cohort.functional_group = mock_functional_group centroid_key = 0 mock_get_community_by_key = mocker.Mock() + mock_community = mocker.Mock() + mock_community.occupancy = {mock_functional_group.name: {}} + mock_get_community_by_key.return_value = mock_community # Set up the mock for bfs_territory to return a predefined set of cells mock_bfs_territory.return_value = [0, 1, 3, 4] # Initialize the AnimalCommunity instance and set up grid dimensions - animal_community_instance.data = mocker.Mock() animal_community_instance.data.grid.cell_nx = 3 animal_community_instance.data.grid.cell_ny = 3 @@ -732,7 +754,7 @@ def test_initialize_territory( # Check that AnimalTerritory was instantiated with the correct parameters mock_animal_territory.assert_called_once_with( - [0, 1, 3, 4], mock_get_community_by_key + centroid_key, [0, 1, 3, 4], mock_get_community_by_key ) # Check that the territory was assigned to the cohort @@ -741,3 +763,12 @@ def test_initialize_territory( # Ensure no additional unexpected calls were made assert mock_bfs_territory.call_count == 1 assert mock_animal_territory.call_count == 1 + + # Check that the occupancy was updated correctly + occupancy_percentage = 1.0 / len(mock_bfs_territory.return_value) + for cell_key in mock_bfs_territory.return_value: + mock_get_community_by_key.assert_any_call(cell_key) + assert ( + mock_community.occupancy[mock_functional_group.name][mock_cohort] + == occupancy_percentage + ) From c68a9e7ce6d808e9ebf31542f78a1d9102608b5f Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 25 Jul 2024 14:51:47 +0100 Subject: [PATCH 25/62] Updated animal model tests. --- tests/models/animals/test_animal_cohorts.py | 8 ------ tests/models/animals/test_animal_model.py | 20 ++++++--------- .../models/animal/animal_cohorts.py | 10 +++++++- .../models/animal/animal_communities.py | 8 +++--- .../models/animal/animal_territories.py | 2 +- .../models/animal/plant_resources.py | 25 +++++++++++++++---- 6 files changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 11e20e06e..7bd82d67b 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -81,14 +81,6 @@ def prey_cohort_instance( ) -@pytest.fixture -def carcass_pool_instance(): - """Fixture for an carcass pool used in tests.""" - from virtual_ecosystem.models.animal.decay import CarcassPool - - return CarcassPool(0.0, 0.0) - - @pytest.mark.usefixtures("mocker") class TestAnimalCohort: """Test AnimalCohort class.""" diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index e93cbe4ef..428d8582d 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -281,36 +281,32 @@ def test_update_method_time_index_argument( assert True -def test_calculate_litter_additions(functional_group_list_instance): +def test_calculate_litter_additions( + functional_group_list_instance, animal_data_for_model_instance +): """Test that litter additions from animal model are calculated correctly.""" from virtual_ecosystem.core.config import Config from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.core.data import Data - from virtual_ecosystem.core.grid import Grid from virtual_ecosystem.models.animal.animal_model import AnimalModel # Build the config object and core components config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') core_components = CoreComponents(config) - # Create a small data object to work with - grid = Grid(cell_nx=2, cell_ny=2) - data = Data(grid) - # Use it to initialise the model model = AnimalModel( - data=data, + data=animal_data_for_model_instance, core_components=core_components, functional_groups=functional_group_list_instance, ) # Update the waste pools - decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6] + decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6, 0, 0, 0, 0, 0] for energy, community in zip(decomposed_excrement, model.communities.values()): community.excrement_pool.decomposed_energy = energy - decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8] + decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8, 0, 0, 0, 0, 0] for energy, community in zip(decomposed_carcasses, model.communities.values()): community.carcass_pool.decomposed_energy = energy @@ -320,11 +316,11 @@ def test_calculate_litter_additions(functional_group_list_instance): # Check that litter addition pools are as expected assert np.allclose( litter_additions["decomposed_excrement"], - [5e-08, 8e-07, 8.42857e-07, 3.28571e-05], + [5e-08, 8e-07, 8.42857e-07, 3.28571e-05, 0, 0, 0, 0, 0], ) assert np.allclose( litter_additions["decomposed_carcasses"], - [1.0714e-4, 4.8571e-4, 1.15714e-3, 2.42857e-3], + [1.0714e-4, 4.8571e-4, 1.15714e-3, 2.42857e-3, 0, 0, 0, 0, 0], ) # Check that the function has reset the pools correctly diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index e67a69c13..272253afe 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -333,6 +333,10 @@ def get_eaten( # Update the number of individuals in the prey cohort self.individuals -= actual_individuals_killed + # set cohort to not alive if all the individuals are dead + if self.individuals <= 0: + self.is_alive = False + # Find the intersection of prey and predator territories intersection_carcass_pools = self.territory.find_intersecting_carcass_pools( predator.territory @@ -595,7 +599,7 @@ def delta_mass_predation( This is Madingley's delta_assimilation_mass_predation - TODO: move defecate + TODO: rethink defecate location Args: animal_list: A sequence of animal cohorts that can be consumed by the @@ -654,6 +658,7 @@ def delta_mass_herbivory( ) -> float: """This method handles mass assimilation from herbivory. + TODO: rethink defecate location TODO: update name Args: @@ -674,6 +679,9 @@ def delta_mass_herbivory( # Update total mass gained by the herbivore total_consumed_mass += actual_consumed_mass + # Process waste generated from predation, separate from predation b/c diff waste + self.defecate(excrement_pools, total_consumed_mass) + return total_consumed_mass def forage_cohort( diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index e62e520fd..f6bdae272 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -162,6 +162,8 @@ def initialize_territory( occupancy_percentage ) + territory.update_territory() + def reinitialize_territory( self, cohort: AnimalCohort, @@ -384,6 +386,7 @@ def forage_community(self) -> None: Cohorts with no remaining individuals post-foraging are marked for death. + TODO: find a more elegant way to remove dead cohorts between foraging bouts """ # Generate the plant resources for foraging. @@ -405,9 +408,8 @@ def forage_community(self) -> None: excrement_pools=excrement_list, ) - # Check if the cohort has been depleted to zero individuals post-foraging - if consumer_cohort.individuals == 0: - self.remove_dead_cohort(consumer_cohort) + # temporary solution + self.remove_dead_cohort_community() def collect_prey( self, consumer_cohort: AnimalCohort diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 766d27a6f..1b75c302f 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -52,7 +52,7 @@ def __init__( self.territory_carcasses: Sequence[DecayPool] = [] """A list of carcass pools present in the territory.""" - def update_territory(self, consumer_cohort: Consumer) -> None: + def update_territory(self) -> None: """Update territory details at initialization and after migration. Args: diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 9d03b2d2b..4723217b6 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -4,6 +4,8 @@ from __future__ import annotations +from collections.abc import Sequence + from virtual_ecosystem.core.data import Data from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool @@ -49,7 +51,10 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: """Whether the cohort is alive [True] or dead [False].""" def get_eaten( - self, consumed_mass: float, herbivore: Consumer, excrement_pool: DecayPool + self, + consumed_mass: float, + herbivore: Consumer, + excrement_pools: Sequence[DecayPool], ) -> float: """This function handles herbivory on PlantResources. @@ -58,7 +63,8 @@ def get_eaten( Args: consumed_mass: The mass intended to be consumed by the herbivore. herbivore: The Consumer (AnimalCohort) consuming the PlantResources. - excrement_pool: The pool to which remains of uneaten plant material is added + excrement_pools: The pools to which remains of uneaten plant material + is added Returns: The actual mass consumed by the herbivore, adjusted for efficiencies. @@ -79,9 +85,18 @@ def get_eaten( excess_mass = actual_consumed_mass * ( 1 - herbivore.functional_group.mechanical_efficiency ) - excrement_pool.decomposed_energy += ( - excess_mass * self.constants.energy_density["plant"] - ) + + # the number of communities over which the feces are to be distributed + number_communities = len(excrement_pools) + + excreta_mass_per_community = ( + excess_mass / number_communities + ) * self.constants.nitrogen_excreta_proportion + + for excrement_pool in excrement_pools: + excrement_pool.decomposed_energy += ( + excreta_mass_per_community * self.constants.energy_density["plant"] + ) # Return the net mass gain of herbivory, considering both mechanical and # digestive efficiencies From 81cd1f4d786ab53b1602526941a1abcd8da71178 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 25 Jul 2024 15:32:19 +0100 Subject: [PATCH 26/62] Finished revising all animal tests for multi grid occupancy. --- tests/models/animals/test_animal_cohorts.py | 2 +- .../models/animals/test_animal_territories.py | 3 +- tests/models/animals/test_plant_resources.py | 2 +- .../models/animal/plant_resources.py | 28 +++++-------------- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 7bd82d67b..e096f9883 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -1257,7 +1257,7 @@ def test_delta_mass_herbivory( ) delta_mass = herbivore_cohort_instance.delta_mass_herbivory( - plant_list_instance, excrement_pool_instance + plant_list_instance, [excrement_pool_instance] ) # Ensure calculate_consumed_mass_herbivory and get_eaten were called correctly diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py index cfac01f6a..c839632c4 100644 --- a/tests/models/animals/test_animal_territories.py +++ b/tests/models/animals/test_animal_territories.py @@ -30,13 +30,12 @@ def mock_get_carcass_pools(self, mocker, animal_territory_instance): def test_update_territory( self, animal_territory_instance, - herbivore_cohort_instance, mock_get_plant_resources, mock_get_excrement_pools, mock_get_carcass_pools, ): """Test for update_territory method.""" - animal_territory_instance.update_territory(herbivore_cohort_instance) + animal_territory_instance.update_territory() mock_get_plant_resources.assert_called_once() mock_get_excrement_pools.assert_called_once() diff --git a/tests/models/animals/test_plant_resources.py b/tests/models/animals/test_plant_resources.py index 3cdbeec02..329f58872 100644 --- a/tests/models/animals/test_plant_resources.py +++ b/tests/models/animals/test_plant_resources.py @@ -15,7 +15,7 @@ def test_get_eaten( initial_excrement_energy = excrement_pool_instance.decomposed_energy actual_mass_gain = plant_instance.get_eaten( - consumed_mass, herbivore_cohort_instance, excrement_pool_instance + consumed_mass, herbivore_cohort_instance, [excrement_pool_instance] ) # Check if the plant mass has been correctly reduced diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 4723217b6..acd922932 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -56,19 +56,8 @@ def get_eaten( herbivore: Consumer, excrement_pools: Sequence[DecayPool], ) -> float: - """This function handles herbivory on PlantResources. + """This function handles herbivory on PlantResources.""" - TODO: plant waste should flow to a litter pool of some kind - - Args: - consumed_mass: The mass intended to be consumed by the herbivore. - herbivore: The Consumer (AnimalCohort) consuming the PlantResources. - excrement_pools: The pools to which remains of uneaten plant material - is added - - Returns: - The actual mass consumed by the herbivore, adjusted for efficiencies. - """ # Check if the requested consumed mass is more than the available mass actual_consumed_mass = min(self.mass_current, consumed_mass) @@ -86,17 +75,14 @@ def get_eaten( 1 - herbivore.functional_group.mechanical_efficiency ) - # the number of communities over which the feces are to be distributed - number_communities = len(excrement_pools) - - excreta_mass_per_community = ( - excess_mass / number_communities - ) * self.constants.nitrogen_excreta_proportion + # Calculate the energy to be added to each excrement pool + excreta_energy_per_pool = ( + excess_mass * self.constants.energy_density["plant"] + ) / len(excrement_pools) + # Distribute the excreta energy across the excrement pools for excrement_pool in excrement_pools: - excrement_pool.decomposed_energy += ( - excreta_mass_per_community * self.constants.energy_density["plant"] - ) + excrement_pool.decomposed_energy += excreta_energy_per_pool # Return the net mass gain of herbivory, considering both mechanical and # digestive efficiencies From 4dbb2d89b85bb81f93c231ad93d35a07cd438db2 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 26 Jul 2024 12:51:12 +0100 Subject: [PATCH 27/62] Added temporary solution to problem with plant data and ve_run testing. --- virtual_ecosystem/models/animal/plant_resources.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index acd922932..069c76682 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -23,6 +23,8 @@ class PlantResources: of plant resources, diversification to fruit and other resources and probably plant cohort specific herbivory. + TODO: fix mass_current after resolving example data questions + Args: data: A Data object containing information from the plants model. cell_id: The cell id for the plant community to expose. @@ -32,9 +34,10 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: # Store the data and extract the appropriate plant data self.data = data """A reference to the core data object.""" - self.mass_current: float = ( - data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() - ) + # self.mass_current: float = ( + # data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() + # ) + self.mass_current = 100.0 """The mass of the plant leaf mass [kg].""" self.constants = constants """The animals constants.""" From 4efee3463decf575fcd05fd53a73bb20a17f1b53 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 26 Jul 2024 14:23:03 +0100 Subject: [PATCH 28/62] Added test for reinitialize territory. --- .../models/animals/test_animal_communities.py | 46 +++++++++++++++++++ .../models/animal/animal_communities.py | 2 - .../models/animal/plant_resources.py | 2 +- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py index 3e5ed139d..6690a472e 100644 --- a/tests/models/animals/test_animal_communities.py +++ b/tests/models/animals/test_animal_communities.py @@ -772,3 +772,49 @@ def test_initialize_territory( mock_community.occupancy[mock_functional_group.name][mock_cohort] == occupancy_percentage ) + + def test_reinitialize_territory( + self, + animal_community_instance, + animal_cohort_instance, + animal_territory_instance, + mocker, + ): + """Testing reinitialize_territory.""" + # Spy on the initialize_territory method within the animal_community_instance + spy_initialize_territory = mocker.spy( + animal_community_instance, "initialize_territory" + ) + + # Spy on the abandon_communities method within the animal_territory_instance + spy_abandon_communities = mocker.spy( + animal_territory_instance, "abandon_communities" + ) + + animal_community_instance.community_key = 0 + + # Mock the get_community_by_key callable + get_community_by_key = mocker.MagicMock() + + # Call the reinitialize_territory method + animal_community_instance.reinitialize_territory( + animal_cohort_instance, + animal_community_instance.community_key, + get_community_by_key, + ) + + # Check if abandon_communities was called once + spy_abandon_communities.assert_called_once_with(animal_cohort_instance) + + # Check if initialize_territory was called with correct arguments + spy_initialize_territory.assert_called_once_with( + animal_cohort_instance, + animal_community_instance.community_key, + get_community_by_key, + ) + + # Check if the territory was updated correctly + assert ( + animal_territory_instance.centroid + == animal_community_instance.community_key + ) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index f6bdae272..518d98158 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -123,7 +123,6 @@ def initialize_territory( """This initializes the territory occupied by the cohort. TODO: update the territory size to cell number conversion using grid size - TODO: needs test Args: cohort: The animal cohort occupying the territory. @@ -173,7 +172,6 @@ def reinitialize_territory( """This initializes the territory occupied by the cohort. TODO: update the territory size to cell number conversion using grid size - TODO: needs test Args: cohort: The animal cohort occupying the territory. diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 069c76682..28b110b32 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -37,7 +37,7 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: # self.mass_current: float = ( # data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() # ) - self.mass_current = 100.0 + self.mass_current = 100000.0 """The mass of the plant leaf mass [kg].""" self.constants = constants """The animals constants.""" From a777fc2ab19e4082987beb4f0903923c86efdec1 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 29 Jul 2024 11:23:40 +0100 Subject: [PATCH 29/62] Small docstring fix in animal communities. --- virtual_ecosystem/models/animal/animal_communities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 518d98158..0aa604bda 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -43,7 +43,7 @@ class AnimalCommunity: community_key: The integer key of the cell id for this community neighbouring_keys: A list of cell id keys for neighbouring communities get_community_by_key: A function to return a designated AnimalCommunity by - integer key. + integer key. """ def __init__( From fbae47bffd7b6df1473a7a81393e8b8e87991065 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 30 Jul 2024 15:05:08 +0100 Subject: [PATCH 30/62] Added a mass check on forage_cohort. --- virtual_ecosystem/models/animal/animal_cohorts.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 272253afe..c33228bc4 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -704,6 +704,10 @@ def forage_cohort( LOGGER.warning("No individuals in cohort to forage.") return + if self.mass_current == 0: + LOGGER.warning("No mass left in cohort to forage.") + return + # Herbivore diet if self.functional_group.diet == DietType.HERBIVORE and plant_list: consumed_mass = self.delta_mass_herbivory( From dddfa6b2dde1b33881f778d29b51a9a64d5780f7 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 30 Jul 2024 15:37:16 +0100 Subject: [PATCH 31/62] Adjusted defecate docstring to fix doc build errors. --- virtual_ecosystem/models/animal/animal_cohorts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index c33228bc4..0cf6c8539 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -194,7 +194,7 @@ def defecate( TODO: Update with stoichiometry Args: - excrement_pools: The ExcrementPool objects in the cohort's territory in + excrement_pools: The excrement pool objects in the cohort's territory in which waste is deposited. mass_consumed: The amount of mass flowing through cohort digestion. """ From 1908f0ae017b276e367ccfbbc004b22f8978e826 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 30 Jul 2024 16:49:09 +0100 Subject: [PATCH 32/62] Modified test_generate_animal_model to deal with stochastic warnings. --- tests/models/animals/test_animal_model.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 428d8582d..16f62cbdb 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -204,8 +204,21 @@ def test_generate_animal_model( # Run the update step (once this does something should check output) model.update(time_index=0) + # Filter out stochastic log entries + filtered_records = [ + record + for record in caplog.records + if "No individuals in cohort to forage." not in record.message + ] + + # Create a new caplog object to pass to log_check + class FilteredCaplog: + records = filtered_records + + filtered_caplog = FilteredCaplog() + # Final check that expected logging entries are produced - log_check(caplog, expected_log_entries) + log_check(filtered_caplog, expected_log_entries) for record in caplog.records: print(f"Level: {record.levelname}, Message: {record.message}") From 8c68c149850e8fe35666a431ea25521c0ac9f7bf Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 30 Jul 2024 16:55:55 +0100 Subject: [PATCH 33/62] Fixed defecate docstring bug. --- virtual_ecosystem/models/animal/animal_cohorts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 0cf6c8539..41a6ccd24 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -194,8 +194,8 @@ def defecate( TODO: Update with stoichiometry Args: - excrement_pools: The excrement pool objects in the cohort's territory in - which waste is deposited. + excrement_pools: The ExcrementPool objects in the cohort's territory in + which waste is deposited. mass_consumed: The amount of mass flowing through cohort digestion. """ # the number of communities over which the feces are to be distributed From eb5064cff3310b8d5483c12d79732ff9881f0f1d Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 6 Aug 2024 12:27:25 +0100 Subject: [PATCH 34/62] Small todo update in animalcohorts. --- virtual_ecosystem/models/animal/animal_cohorts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 41a6ccd24..5daf466aa 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -730,7 +730,7 @@ def theta_i_j(self, animal_list: Sequence[Consumer]) -> float: Madingley - TODO: current format makes no sense, dig up the details in the supp + TODO: current mass bin format makes no sense, dig up the details in the supp TODO: update A_cell with real reference to grid size TODO: update name From 08f8559a99e41cba4b37af3043f8b84be537081f Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 15 Aug 2024 15:24:46 +0100 Subject: [PATCH 35/62] Refactor of code such that the AnimalCommunity class is removed. --- .../models/animal/animal_cohorts.py | 154 +++-- .../models/animal/animal_communities.py | 547 +---------------- .../models/animal/animal_model.py | 574 +++++++++++++++--- .../models/animal/animal_territories.py | 191 +++--- .../models/animal/plant_resources.py | 9 +- virtual_ecosystem/models/animal/protocols.py | 3 + 6 files changed, 723 insertions(+), 755 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 5daf466aa..d96dd5b58 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -2,22 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from math import ceil, exp, sqrt +from uuid import uuid4 from numpy import timedelta64 import virtual_ecosystem.models.animal.scaling_functions as sf +from virtual_ecosystem.core.grid import Grid from virtual_ecosystem.core.logger import LOGGER +from virtual_ecosystem.models.animal.animal_territories import ( + AnimalTerritory, + bfs_territory, +) from virtual_ecosystem.models.animal.animal_traits import DietType from virtual_ecosystem.models.animal.constants import AnimalConsts +from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup -from virtual_ecosystem.models.animal.protocols import ( - Consumer, - DecayPool, - Resource, - Territory, -) +from virtual_ecosystem.models.animal.plant_resources import PlantResources class AnimalCohort: @@ -29,7 +30,8 @@ def __init__( mass: float, age: float, individuals: int, - territory: Territory, + centroid_key: int, + grid: Grid, constants: AnimalConsts = AnimalConsts(), ) -> None: if age < 0: @@ -51,10 +53,14 @@ def __init__( """The age of the animal cohort [days].""" self.individuals = individuals """The number of individuals in this cohort.""" - self.territory = territory - """The territory of animal communities occupied by the cohort.""" + self.centroid_key = centroid_key + """The centroid key of the cohort's territory.""" + self.grid = grid + """The the grid structure of the simulation.""" self.constants = constants """Animal constants.""" + self.id = uuid4() + """A unique identifier for the cohort.""" self.damuth_density: int = sf.damuths_law( self.functional_group.adult_mass, self.functional_group.damuths_law_terms ) @@ -69,7 +75,7 @@ def __init__( """The amount of time [days] since reaching adult body-mass.""" self.reproductive_mass: float = 0.0 """The pool of biomass from which the material of reproduction is drawn.""" - self.prey_groups = sf.prey_group_selection( + self.prey_groups: dict[str, tuple[float, float]] = sf.prey_group_selection( self.functional_group.diet, self.functional_group.adult_mass, self.functional_group.prey_scaling, @@ -77,6 +83,9 @@ def __init__( """The identification of useable food resources.""" self.territory_size = sf.territory_size(self.functional_group.adult_mass) """The size in hectares of the animal cohorts territory.""" + self.occupancy_proportion: float = 1.0 / self.territory_size + """The proportion of the cohort that is within a territorial given grid cell.""" + self._initialize_territory(centroid_key) # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement @@ -84,6 +93,43 @@ def __init__( self.decay_fraction_carcasses: float = self.constants.decay_fraction_carcasses """The fraction of carcass biomass which decays before it gets consumed.""" + def get_territory_cells(self, centroid_key: int) -> list[int]: + """This calls bfs_territory to determine the scope of the territory. + + Args: + centroid_key: The central grid cell key of the territory. + + """ + + # Each grid cell is 1 hectare, territory size in grids is the same as hectares + target_cell_number = int(self.territory_size) + + # Perform BFS to determine the territory cells + territory_cells = bfs_territory( + centroid_key, + target_cell_number, + self.grid.cell_nx, + self.grid.cell_ny, + ) + + return territory_cells + + def _initialize_territory( + self, + centroid_key: int, + ) -> None: + """This initializes the territory occupied by the cohort. + + Args: + centroid_key: The grid cell key anchoring the territory. + get_community_by_key: The method for accessing animal communities by key. + """ + + territory_cells = self.get_territory_cells(centroid_key) + + # Generate the territory + self.territory = AnimalTerritory(centroid_key, territory_cells) + def metabolize(self, temperature: float, dt: timedelta64) -> float: """The function to reduce body mass through metabolism. @@ -126,9 +172,7 @@ def metabolize(self, temperature: float, dt: timedelta64) -> float: # in data object return actual_mass_metabolized * self.individuals - def excrete( - self, excreta_mass: float, excrement_pools: Sequence[DecayPool] - ) -> None: + def excrete(self, excreta_mass: float, excrement_pools: set[ExcrementPool]) -> None: """Transfers nitrogenous metabolic wastes to the excrement pool. This method will not be fully implemented until the stoichiometric rework. All @@ -180,7 +224,7 @@ def respire(self, excreta_mass: float) -> float: def defecate( self, - excrement_pools: Sequence[DecayPool], + excrement_pools: list[ExcrementPool], mass_consumed: float, ) -> None: """Transfer waste mass from an animal cohort to the excrement pools. @@ -242,7 +286,7 @@ def increase_age(self, dt: timedelta64) -> None: self.time_to_maturity = self.age def die_individual( - self, number_dead: int, carcass_pools: Sequence[DecayPool] + self, number_dead: int, carcass_pools: list[CarcassPool] ) -> None: """The function to reduce the number of individuals in the cohort through death. @@ -272,10 +316,12 @@ def die_individual( self.update_carcass_pool(carcass_mass, carcass_pools) def update_carcass_pool( - self, carcass_mass: float, carcass_pools: Sequence[DecayPool] + self, carcass_mass: float, carcass_pools: list[CarcassPool] ) -> None: """Updates the carcass pools based on consumed mass and predator's efficiency. + TODO: move to animal model? + Args: carcass_mass: The total mass consumed from the prey cohort. carcass_pools: The pools to which remains of eaten individuals are @@ -296,7 +342,8 @@ def update_carcass_pool( def get_eaten( self, potential_consumed_mass: float, - predator: Consumer, + predator: AnimalCohort, + carcass_pools: dict[int, set[CarcassPool]], ) -> float: """Removes individuals according to mass demands of a predation event. @@ -307,7 +354,8 @@ def get_eaten( Args: potential_consumed_mass: The mass intended to be consumed by the predator. predator: The predator consuming the cohort. - carcass_pool: The pool to which remains of eaten individuals are delivered. + carcass_pools: The pools to which remains of eaten individuals are + delivered. Returns: The actual mass consumed by the predator, closely matching consumed_mass. @@ -339,7 +387,7 @@ def get_eaten( # Find the intersection of prey and predator territories intersection_carcass_pools = self.territory.find_intersecting_carcass_pools( - predator.territory + predator.territory, carcass_pools ) # Update the carcass pool with carcass mass @@ -363,7 +411,7 @@ def calculate_alpha(self) -> float: return sf.alpha_i_k(self.constants.alpha_0_herb, self.mass_current) def calculate_potential_consumed_biomass( - self, target_plant: Resource, alpha: float + self, target_plant: PlantResources, alpha: float ) -> float: """Calculate potential consumed biomass for the target plant. @@ -387,7 +435,7 @@ def calculate_potential_consumed_biomass( return sf.k_i_k(alpha, phi, target_plant.mass_current, A_cell) def calculate_total_handling_time_for_herbivory( - self, plant_list: Sequence[Resource], alpha: float + self, plant_list: list[PlantResources], alpha: float ) -> float: """Calculate total handling time across all plant resources. @@ -399,7 +447,7 @@ def calculate_total_handling_time_for_herbivory( TODO: MGO - rework for territories Args: - plant_list: A sequence of plant resources available for consumption by the + plant_list: A list of plant resources available for consumption by the cohort. alpha: The search efficiency rate of the herbivore cohort. @@ -421,7 +469,9 @@ def calculate_total_handling_time_for_herbivory( for plant in plant_list ) - def F_i_k(self, plant_list: Sequence[Resource], target_plant: Resource) -> float: + def F_i_k( + self, plant_list: list[PlantResources], target_plant: PlantResources + ) -> float: """Method to determine instantaneous herbivory rate on plant k. This method integrates the calculated search efficiency, potential consumed @@ -432,7 +482,7 @@ def F_i_k(self, plant_list: Sequence[Resource], target_plant: Resource) -> float TODO: update name Args: - plant_list: A sequence of plant resources available for consumption by the + plant_list: A list of plant resources available for consumption by the cohort. target_plant: The specific plant resource being targeted by the herbivore cohort for consumption. @@ -532,12 +582,12 @@ def calculate_total_handling_time_for_predation(self) -> float: ) def F_i_j_individual( - self, animal_list: Sequence[Consumer], target_cohort: Consumer + self, animal_list: list[AnimalCohort], target_cohort: AnimalCohort ) -> float: """Method to determine instantaneous predation rate on cohort j. Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. target_cohort: The prey cohort from which mass will be consumed. @@ -557,7 +607,7 @@ def F_i_j_individual( return N_i * (k_target / (1 + total_handling_t)) * (1 / N_target) def calculate_consumed_mass_predation( - self, animal_list: Sequence[Consumer], target_cohort: Consumer + self, animal_list: list[AnimalCohort], target_cohort: AnimalCohort ) -> float: """Calculates the mass to be consumed from a prey cohort by the predator. @@ -568,7 +618,7 @@ def calculate_consumed_mass_predation( TODO: Replace delta_t with time step reference Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. target_cohort: The prey cohort from which mass will be consumed. @@ -592,8 +642,9 @@ def calculate_consumed_mass_predation( def delta_mass_predation( self, - animal_list: Sequence[Consumer], - excrement_pools: Sequence[DecayPool], + animal_list: list[AnimalCohort], + excrement_pools: list[ExcrementPool], + carcass_pools: dict[int, set[CarcassPool]], ) -> float: """This method handles mass assimilation from predation. @@ -602,9 +653,10 @@ def delta_mass_predation( TODO: rethink defecate location Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. excrement_pools: The pools representing the excrement in the territory. + carcass_pools: The pools to which animal carcasses are delivered. Returns: The change in mass experienced by the predator. @@ -618,7 +670,9 @@ def delta_mass_predation( animal_list, prey_cohort ) # Call get_eaten on the prey cohort to update its mass and individuals - actual_consumed_mass = prey_cohort.get_eaten(consumed_mass, self) + actual_consumed_mass = prey_cohort.get_eaten( + consumed_mass, self, carcass_pools + ) # Update total mass gained by the predator total_consumed_mass += actual_consumed_mass @@ -627,7 +681,7 @@ def delta_mass_predation( return total_consumed_mass def calculate_consumed_mass_herbivory( - self, plant_list: Sequence[Resource], target_plant: Resource + self, plant_list: list[PlantResources], target_plant: PlantResources ) -> float: """Calculates the mass to be consumed from a plant resource by the herbivore. @@ -638,7 +692,7 @@ def calculate_consumed_mass_herbivory( TODO: Replace delta_t with actual time step reference Args: - plant_list: A sequence of plant resources that can be consumed by the + plant_list: A list of plant resources that can be consumed by the herbivore. target_plant: The plant resource from which mass will be consumed. @@ -654,7 +708,9 @@ def calculate_consumed_mass_herbivory( return consumed_mass def delta_mass_herbivory( - self, plant_list: Sequence[Resource], excrement_pools: Sequence[DecayPool] + self, + plant_list: list[PlantResources], + excrement_pools: list[ExcrementPool], ) -> float: """This method handles mass assimilation from herbivory. @@ -662,7 +718,7 @@ def delta_mass_herbivory( TODO: update name Args: - plant_list: A sequence of plant resources available for herbivory. + plant_list: A list of plant resources available for herbivory. excrement_pools: The pools representing the excrement in the territory. Returns: @@ -686,16 +742,18 @@ def delta_mass_herbivory( def forage_cohort( self, - plant_list: Sequence[Resource], - animal_list: Sequence[Consumer], - excrement_pools: Sequence[DecayPool], + plant_list: list[PlantResources], + animal_list: list[AnimalCohort], + excrement_pools: list[ExcrementPool], + carcass_pools: dict[int, set[CarcassPool]], ) -> None: """This function handles selection of resources from a list for consumption. Args: - plant_list: A sequence of plant resources available for herbivory. - animal_list: A sequence of animal cohorts available for predation. - excrement_pools: A pool representing the excrement in the grid cell. + plant_list: A list of plant resources available for herbivory. + animal_list: A list of animal cohorts available for predation. + excrement_pools: The pools representing the excrement in the grid cell. + carcass_pools: The pools to which animal carcasses are delivered. Return: A float value of the net change in consumer mass due to foraging. @@ -718,11 +776,13 @@ def forage_cohort( # Carnivore diet elif self.functional_group.diet == DietType.CARNIVORE and animal_list: # Calculate the mass gained from predation - consumed_mass = self.delta_mass_predation(animal_list, excrement_pools) + consumed_mass = self.delta_mass_predation( + animal_list, excrement_pools, carcass_pools + ) # Update the predator's mass with the total gained mass self.eat(consumed_mass) - def theta_i_j(self, animal_list: Sequence[Consumer]) -> float: + def theta_i_j(self, animal_list: list[AnimalCohort]) -> float: """Cumulative density method for delta_mass_predation. The cumulative density of organisms with a mass lying within the same predator @@ -735,7 +795,7 @@ def theta_i_j(self, animal_list: Sequence[Consumer]) -> float: TODO: update name Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. Returns: @@ -839,7 +899,7 @@ def migrate_juvenile_probability(self) -> float: return min(1.0, probability_of_dispersal) def inflict_non_predation_mortality( - self, dt: float, carcass_pools: Sequence[DecayPool] + self, dt: float, carcass_pools: list[CarcassPool] ) -> None: """Inflict combined background, senescence, and starvation mortalities. diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py index 0aa604bda..3dc0632e2 100644 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ b/virtual_ecosystem/models/animal/animal_communities.py @@ -2,32 +2,17 @@ from __future__ import annotations -import importlib -import random -from collections.abc import Callable, Iterable, MutableSequence, Sequence +from collections.abc import Callable, Iterable from itertools import chain -from math import ceil - -from numpy import timedelta64 from virtual_ecosystem.core.data import Data -from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_traits import DevelopmentType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool from virtual_ecosystem.models.animal.functional_group import ( FunctionalGroup, - get_functional_group_by_name, ) from virtual_ecosystem.models.animal.plant_resources import PlantResources -from virtual_ecosystem.models.animal.protocols import ( - Consumer, - DecayPool, - Resource, - Territory, -) -from virtual_ecosystem.models.animal.scaling_functions import damuths_law class AnimalCommunity: @@ -113,533 +98,3 @@ def all_occupying_cohorts(self) -> Iterable[AnimalCohort]: return chain.from_iterable( cohort_dict.keys() for cohort_dict in self.occupancy.values() ) - - def initialize_territory( - self, - cohort: AnimalCohort, - centroid_key: int, - get_community_by_key: Callable[[int], AnimalCommunity], - ) -> None: - """This initializes the territory occupied by the cohort. - - TODO: update the territory size to cell number conversion using grid size - - Args: - cohort: The animal cohort occupying the territory. - centroid_key: The community key anchoring the territory. - get_community_by_key: The method for accessing animal communities by key. - """ - AnimalTerritory = importlib.import_module( - "virtual_ecosystem.models.animal.animal_territories" - ).AnimalTerritory - - bfs_territory = importlib.import_module( - "virtual_ecosystem.models.animal.animal_territories" - ).bfs_territory - - # Each grid cell is 1 hectare, territory size in grids is the same as hectares - target_cell_number = int(cohort.territory_size) - - # Perform BFS to determine the territory cells - territory_cells = bfs_territory( - centroid_key, - target_cell_number, - self.data.grid.cell_nx, - self.data.grid.cell_ny, - ) - - # Generate the territory - territory = AnimalTerritory(centroid_key, territory_cells, get_community_by_key) - # Add the territory to the cohort's attributes - cohort.territory = territory - - # Update the occupancy of the cohort in each community within the territory - occupancy_percentage = 1.0 / len(territory_cells) - for cell_key in territory_cells: - community = get_community_by_key(cell_key) - community.occupancy[cohort.functional_group.name][cohort] = ( - occupancy_percentage - ) - - territory.update_territory() - - def reinitialize_territory( - self, - cohort: AnimalCohort, - centroid_key: int, - get_community_by_key: Callable[[int], AnimalCommunity], - ) -> None: - """This initializes the territory occupied by the cohort. - - TODO: update the territory size to cell number conversion using grid size - - Args: - cohort: The animal cohort occupying the territory. - centroid_key: The community key anchoring the territory. - get_community_by_key: The method for accessing animal communities by key. - """ - # remove existing occupancies - cohort.territory.abandon_communities(cohort) - # reinitialize the territory - self.initialize_territory(cohort, centroid_key, get_community_by_key) - - def populate_community(self) -> None: - """This function creates an instance of each functional group. - - Currently, this is the simplest implementation of populating the animal model. - In each AnimalCommunity one AnimalCohort of each FunctionalGroup type is - generated. So the more functional groups that are made, the denser the animal - community will be. This function will need to be reworked dramatically later on. - - Currently, the number of individuals in a cohort is handled using Damuth's Law, - which only holds for mammals. - - TODO: Move populate_community to following Madingley instead of damuth - - """ - for functional_group in self.functional_groups: - individuals = damuths_law( - functional_group.adult_mass, functional_group.damuths_law_terms - ) - - # create a cohort of the functional group - cohort = AnimalCohort( - functional_group, - functional_group.adult_mass, - 0.0, - individuals, - DefaultTerritory(), - self.constants, - ) - # add the cohort to the community's list of animal cohorts @ centroid - self.animal_cohorts[functional_group.name].append(cohort) - - # add the cohort to the community with 100% occupancy initially - self.occupancy[functional_group.name][cohort] = 1.0 - - # generate a territory for the cohort - self.initialize_territory( - cohort, - self.community_key, - self.get_community_by_key, - ) - - def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: - """Function to move an AnimalCohort between AnimalCommunity objects. - - This function takes a cohort and a destination community, changes the - centroid of the cohort's territory to be the new community, and then - reinitializes the territory around the new centroid. - - TODO: travel distance should be a function of body-size or locomotion once - multi-grid occupancy is integrated. - - Args: - migrant: The AnimalCohort moving between AnimalCommunities. - destination: The AnimalCommunity the cohort is moving to. - - """ - - self.animal_cohorts[migrant.name].remove(migrant) - destination.animal_cohorts[migrant.name].append(migrant) - - # Regenerate a territory for the cohort at the destination community - destination.reinitialize_territory( - migrant, - destination.community_key, - destination.get_community_by_key, - ) - - def migrate_community(self) -> None: - """This handles migrating all cohorts with a centroid in the community. - - This migration method initiates migration for two reasons: - 1) The cohort is starving and needs to move for a chance at resource access - 2) An initial migration event immediately after birth. - - TODO: MGO - migrate distance mod for larger territories? - - - """ - for cohort in self.all_animal_cohorts: - is_starving = cohort.is_below_mass_threshold( - self.constants.dispersal_mass_threshold - ) - is_juvenile_and_migrate = ( - cohort.age == 0.0 - and random.random() <= cohort.migrate_juvenile_probability() - ) - migrate = is_starving or is_juvenile_and_migrate - - if not migrate: - continue - - destination_key = random.choice(self.neighbouring_keys) - destination = self.get_community_by_key(destination_key) - self.migrate(cohort, destination) - - def remove_dead_cohort(self, cohort: AnimalCohort) -> None: - """Remove a dead cohort from a community. - - Args: - cohort: The AnimalCohort instance that has lost all individuals. - - """ - - if not cohort.is_alive: - self.animal_cohorts[cohort.name].remove(cohort) - elif cohort.is_alive: - LOGGER.exception("An animal cohort which is alive cannot be removed.") - - def remove_dead_cohort_community(self) -> None: - """This handles remove_dead_cohort for all cohorts in a community.""" - for cohort in chain.from_iterable(self.animal_cohorts.values()): - if cohort.individuals <= 0: - cohort.is_alive = False - self.remove_dead_cohort(cohort) - - def birth(self, parent_cohort: AnimalCohort) -> None: - """Produce a new AnimalCohort through reproduction. - - A cohort can only reproduce if it has an excess of reproductive mass above a - certain threshold. The offspring will be an identical cohort of adults - with age 0 and mass=birth_mass. A new territory, likely smaller b/c allometry, - is generated for the newborn cohort. - - The science here follows Madingley. - - TODO: Check whether Madingley discards excess reproductive mass. - TODO: Rework birth mass for indirect developers. - - Args: - parent_cohort: The AnimalCohort instance which is producing a new cohort. - """ - # semelparous organisms use a portion of their non-reproductive mass to make - # offspring and then they die - non_reproductive_mass_loss = 0.0 - if parent_cohort.functional_group.reproductive_type == "semelparous": - non_reproductive_mass_loss = ( - parent_cohort.mass_current - * parent_cohort.constants.semelparity_mass_loss - ) - parent_cohort.mass_current -= non_reproductive_mass_loss - # kill the semelparous parent cohort - parent_cohort.is_alive = False - - number_offspring = ( - int( - (parent_cohort.reproductive_mass + non_reproductive_mass_loss) - / parent_cohort.functional_group.birth_mass - ) - * parent_cohort.individuals - ) - - # reduce reproductive mass by amount used to generate offspring - parent_cohort.reproductive_mass = 0.0 - - offspring_cohort = AnimalCohort( - get_functional_group_by_name( - self.functional_groups, - parent_cohort.functional_group.offspring_functional_group, - ), - parent_cohort.functional_group.birth_mass, - 0.0, - number_offspring, - DefaultTerritory(), - self.constants, - ) - - # generate a territory for the offspring cohort - self.initialize_territory( - offspring_cohort, - self.community_key, - self.get_community_by_key, - ) - - # add a new cohort of the parental type to the community - self.animal_cohorts[parent_cohort.name].append(offspring_cohort) - - if parent_cohort.functional_group.reproductive_type == "semelparous": - self.remove_dead_cohort(parent_cohort) - - def birth_community(self) -> None: - """This handles birth for all cohorts in a community.""" - - # reproduction occurs for cohorts with sufficient reproductive mass - for cohort in self.all_animal_cohorts: - if ( - not cohort.is_below_mass_threshold(self.constants.birth_mass_threshold) - and cohort.functional_group.reproductive_type != "nonreproductive" - ): - self.birth(cohort) - - def forage_community(self) -> None: - """This function organizes the foraging of animal cohorts. - - It loops over every animal cohort in the community and calls the - forage_cohort function with a list of suitable trophic resources. This action - initiates foraging for those resources, with mass transfer details handled - internally by forage_cohort and its helper functions. Future expansions may - include functions for handling scavenging and soil consumption behaviors. - - Cohorts with no remaining individuals post-foraging are marked for death. - - TODO: find a more elegant way to remove dead cohorts between foraging bouts - - """ - # Generate the plant resources for foraging. - - plant_list: Sequence = [self.plant_community] - - for consumer_cohort in self.all_animal_cohorts: - # Prepare the prey list for the consumer cohort - if consumer_cohort.territory is None: - raise ValueError("The cohort's territory hasn't been defined.") - prey_list = consumer_cohort.territory.get_prey(consumer_cohort) - plant_list = consumer_cohort.territory.get_plant_resources() - excrement_list = consumer_cohort.territory.get_excrement_pools() - - # Initiate foraging for the consumer cohort with the prepared resources - consumer_cohort.forage_cohort( - plant_list=plant_list, - animal_list=prey_list, - excrement_pools=excrement_list, - ) - - # temporary solution - self.remove_dead_cohort_community() - - def collect_prey( - self, consumer_cohort: AnimalCohort - ) -> MutableSequence[AnimalCohort]: - """Collect suitable prey for a given consumer cohort. - - This is a helper function for territory.get_prey, it filters suitable prey from - the total list of animal cohorts across the territory. - - TODO: possibly moved to be a territory method - - Args: - consumer_cohort: The AnimalCohort for which a prey list is being collected - - Returns: - A sequence of AnimalCohorts that can be preyed upon. - - """ - prey: MutableSequence = [] - for ( - prey_functional_group, - potential_prey_cohorts, - ) in self.animal_cohorts.items(): - # Skip if this functional group is not a prey of current predator - if prey_functional_group not in consumer_cohort.prey_groups: - continue - - # Get the size range of the prey this predator eats - min_size, max_size = consumer_cohort.prey_groups[prey_functional_group] - - # Filter the potential prey cohorts based on their size - for cohort in potential_prey_cohorts: - if ( - min_size <= cohort.mass_current <= max_size - and cohort.individuals != 0 - and cohort is not consumer_cohort - ): - prey.append(cohort) - - return prey - - def metabolize_community(self, temperature: float, dt: timedelta64) -> None: - """This handles metabolize for all cohorts in a community. - - This method generates a total amount of metabolic waste per cohort and passes - that waste to handler methods for distinguishing between nitrogenous and - carbonaceous wastes as they need depositing in different pools. This will not - be fully implemented until the stoichiometric rework. - - Respiration wastes are totaled because they are CO2 and not tracked spatially. - Excretion wastes are handled cohort by cohort because they will need to be - spatially explicit with multi-grid occupancy. - - TODO: Rework with stoichiometry - - Args: - temperature: Current air temperature (K). - dt: Number of days over which the metabolic costs should be calculated. - - """ - total_carbonaceous_waste = 0.0 - - for cohort in self.all_animal_cohorts: - metabolic_waste_mass = cohort.metabolize(temperature, dt) - total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) - cohort.excrete( - metabolic_waste_mass, - cohort.territory.territory_excrement, - ) - - # Update the total_animal_respiration for this community using community_key. - - self.data["total_animal_respiration"].loc[{"cell_id": self.community_key}] += ( - total_carbonaceous_waste - ) - - def increase_age_community(self, dt: timedelta64) -> None: - """This handles age for all cohorts in a community. - - Args: - dt: Number of days over which the metabolic costs should be calculated. - - """ - for cohort in self.all_animal_cohorts: - cohort.increase_age(dt) - - def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: - """This handles natural mortality for all cohorts in a community. - - This includes background mortality, starvation, and, for mature cohorts, - senescence. - - Args: - dt: Number of days over which the metabolic costs should be calculated. - - """ - number_of_days = float(dt / timedelta64(1, "D")) - for cohort in self.all_animal_cohorts: - cohort.inflict_non_predation_mortality( - number_of_days, cohort.territory.territory_carcasses - ) - if cohort.individuals <= 0: - cohort.is_alive = False - self.remove_dead_cohort(cohort) - - def metamorphose(self, larval_cohort: AnimalCohort) -> None: - """This transforms a larval status cohort into an adult status cohort. - - This method takes an indirect developing cohort in its larval form, - inflicts a mortality rate, and creates an adult cohort of the correct type. - - TODO: Build in a relationship between larval_cohort mass and adult cohort mass. - TODO: Is adult_mass the correct mass threshold? - TODO: If the time step drops below a month, this needs an intermediary stage. - - Args: - larval_cohort: The cohort in its larval stage to be transformed. - """ - - # inflict a mortality - number_dead = ceil( - larval_cohort.individuals * larval_cohort.constants.metamorph_mortality - ) - larval_cohort.die_individual( - number_dead, larval_cohort.territory.territory_carcasses - ) - # collect the adult functional group - adult_functional_group = get_functional_group_by_name( - self.functional_groups, - larval_cohort.functional_group.offspring_functional_group, - ) - # create the adult cohort - adult_cohort = AnimalCohort( - adult_functional_group, - adult_functional_group.birth_mass, - 0.0, - larval_cohort.individuals, - larval_cohort.territory, - self.constants, - ) - - # generate a territory for the adult cohort - self.initialize_territory( - adult_cohort, - self.community_key, - self.get_community_by_key, - ) - - # add a new cohort of the parental type to the community - self.animal_cohorts[adult_cohort.name].append(adult_cohort) - - # remove the larval cohort - larval_cohort.is_alive = False - self.remove_dead_cohort(larval_cohort) - - def metamorphose_community(self) -> None: - """Handle metamorphosis for all applicable cohorts in the community.""" - - for cohort in self.all_animal_cohorts: - if ( - cohort.functional_group.development_type == DevelopmentType.INDIRECT - and (cohort.mass_current >= cohort.functional_group.adult_mass) - ): - self.metamorphose(cohort) - - -class DefaultCommunity(AnimalCommunity): - """A default community that represents an empty or non-functional state.""" - - def __init__(self) -> None: - self.functional_groups: tuple[FunctionalGroup, ...] = () - self.data: Data = self.data - self.community_key: int = -1 - self.neighbouring_keys: list[int] = [] - self.constants: AnimalConsts = AnimalConsts() - self.carcass_pool: CarcassPool = CarcassPool(10000.0, 0.0) - """A pool for animal carcasses within the community.""" - self.excrement_pool: ExcrementPool = ExcrementPool(10000.0, 0.0) - """A pool for excrement within the community.""" - self.plant_community: PlantResources = PlantResources( - data=self.data, - cell_id=self.community_key, - constants=self.constants, - ) - - def collect_prey( - self, consumer_cohort: AnimalCohort - ) -> MutableSequence[AnimalCohort]: - """Default method.""" - return [] - - def get_community_by_key(self, key: int) -> AnimalCommunity: - """Default method.""" - return self - - -class DefaultTerritory(Territory): - """A default territory that represents an empty or non-functional state.""" - - def __init__(self) -> None: - """Default method.""" - self.grid_cell_keys: list[int] = [] - self._get_community_by_key = lambda key: DefaultCommunity() - self.territory_carcasses: Sequence[DecayPool] = [] - self.territory_excrement: Sequence[DecayPool] = [] - - def update_territory(self, consumer_cohort: Consumer) -> None: - """Default method.""" - pass - - def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: - """Default method.""" - return [] - - def get_plant_resources(self) -> MutableSequence[Resource]: - """Default method.""" - return [] - - def get_excrement_pools(self) -> MutableSequence[DecayPool]: - """Default method.""" - return [] - - def get_carcass_pools(self) -> MutableSequence[DecayPool]: - """Default method.""" - return [] - - def find_intersecting_carcass_pools( - self, animal_territory: Territory - ) -> MutableSequence[DecayPool]: - """Default method.""" - return [] - - def abandon_communities(self, consumer_cohort: Consumer) -> None: - """Default method.""" - pass diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index e9f4efeb4..daab8cdda 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -18,8 +18,10 @@ from __future__ import annotations -from math import sqrt +from math import ceil, sqrt +from random import choice, random from typing import Any +from uuid import UUID from numpy import array, timedelta64, zeros from xarray import DataArray @@ -31,9 +33,15 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity +from virtual_ecosystem.models.animal.animal_traits import DevelopmentType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.functional_group import FunctionalGroup +from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool +from virtual_ecosystem.models.animal.functional_group import ( + FunctionalGroup, + get_functional_group_by_name, +) +from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.scaling_functions import damuths_law class AnimalModel( @@ -77,23 +85,51 @@ def __init__( self.update_interval_timedelta = timedelta64(int(days_as_float), "D") """Convert pint update_interval to timedelta64 once during initialization.""" - self._setup_grid_neighbors() + self._setup_grid_neighbours() """Determine grid square adjacency.""" self.functional_groups = functional_groups """List of functional groups in the model.""" - self.communities: dict[int, AnimalCommunity] = {} - """Set empty dict for populating with communities.""" self.model_constants = model_constants """Animal constants.""" + self.plant_resources: dict[int, set[PlantResources]] = { + cell_id: { + PlantResources( + data=self.data, cell_id=cell_id, constants=self.model_constants + ) + } + for cell_id in self.data.grid.cell_id + } + """The plant resource pools in the model with associated grid cell ids.""" + self.excrement_pools: dict[int, set[ExcrementPool]] = { + cell_id: { + ExcrementPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) + } + for cell_id in self.data.grid.cell_id + } + """The excrement pools in the model with associated grid cell ids.""" + self.carcass_pools: dict[int, set[CarcassPool]] = { + cell_id: { + CarcassPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) + } + for cell_id in self.data.grid.cell_id + } + """The carcass pools in the model with associated grid cell ids.""" + + self.cohorts: dict[UUID, AnimalCohort] = {} + """A dictionary of all animal cohorts and their unique ids.""" + self.communities: dict[int, set[AnimalCohort]] = { + cell_id: set() for cell_id in self.data.grid.cell_id + } + """The animal cohorts organized by cell_id.""" self._initialize_communities(functional_groups) """Create the dictionary of animal communities and populate each community with animal cohorts.""" self.setup() """Initialize the data variables used by the animal model.""" - def _setup_grid_neighbors(self) -> None: - """Set up grid neighbors for the model. + def _setup_grid_neighbours(self) -> None: + """Set up grid neighbours for the model. Currently, this is redundant with the set_neighbours method of grid. This will become a more complex animal specific implementation to manage @@ -102,45 +138,37 @@ def _setup_grid_neighbors(self) -> None: """ self.data.grid.set_neighbours(distance=sqrt(self.data.grid.cell_area)) - def get_community_by_key(self, key: int) -> AnimalCommunity: - """Function to return the AnimalCommunity present in a given grid square. - - This function exists principally to provide a callable for AnimalCommunity. - - Args: - key: The specific grid square integer key associated with the community. - - Returns: - The AnimalCommunity object in that grid square. - - """ - return self.communities[key] - def _initialize_communities(self, functional_groups: list[FunctionalGroup]) -> None: - """Initialize the animal communities. + """Initialize the animal communities by creating and populating animal cohorts. Args: functional_groups: The list of functional groups that will populate the model. """ - - # Generate a dictionary of AnimalCommunity objects, one per grid cell. - self.communities = { - k: AnimalCommunity( - functional_groups=functional_groups, - data=self.data, - community_key=k, - neighbouring_keys=list(self.data.grid.neighbours[k]), - get_community_by_key=self.get_community_by_key, - constants=self.model_constants, - ) - for k in self.data.grid.cell_id - } - - # Create animal cohorts in each grid square's animal community according to the - # populate_community method. - for community in self.communities.values(): - community.populate_community() + # Initialize communities dictionary with cell IDs as keys and empty sets for + # cohorts + self.communities = {cell_id: set() for cell_id in self.data.grid.cell_id} + + # Iterate over each cell and functional group to create and populate cohorts + for cell_id in self.data.grid.cell_id: + for functional_group in functional_groups: + # Calculate the number of individuals using Damuth's Law + individuals = damuths_law( + functional_group.adult_mass, functional_group.damuths_law_terms + ) + + # Create a cohort of the functional group + cohort = AnimalCohort( + functional_group=functional_group, + mass=functional_group.adult_mass, + age=0.0, + individuals=individuals, + centroid_key=cell_id, + grid=self.data.grid, + constants=self.model_constants, + ) + self.cohorts[cohort.id] = cohort + self.communities[cell_id].add(cohort) @classmethod def from_config( @@ -188,13 +216,13 @@ def setup(self) -> None: # animal respiration data variable # the array should have one value for each animal community - n_communities = len(self.data.grid.cell_id) + n_grid_cells = len(self.data.grid.cell_id) # Initialize total_animal_respiration as a DataArray with a single dimension: # cell_id total_animal_respiration = DataArray( zeros( - n_communities + n_grid_cells ), # Filled with zeros to start with no carbon production. dims=["cell_id"], coords={"cell_id": self.data.grid.cell_id}, @@ -208,7 +236,7 @@ def setup(self) -> None: functional_group_names = [fg.name for fg in self.functional_groups] # Assuming self.communities is a dict with community_id as keys - community_ids = list(self.communities.keys()) + community_ids = self.data.grid.cell_id # Create a multi-dimensional array for population densities population_densities = DataArray( @@ -237,27 +265,25 @@ def update(self, time_index: int, **kwargs: Any) -> None: events would be simultaneous. The ordering within the method is less a question of the science and more a question of computational logic and stability. - + TODO: update so that it just cycles through the community methods, each of those + will cycle through all cohorts in the model Args: time_index: The index representing the current time step in the data object. **kwargs: Further arguments to the update method. """ - for community in self.communities.values(): - community.forage_community() - community.migrate_community() - community.birth_community() - community.metamorphose_community() - community.metabolize_community( - float(self.data["air_temperature"][0][community.community_key].values), - self.update_interval_timedelta, - ) - community.inflict_non_predation_mortality_community( - self.update_interval_timedelta - ) - community.remove_dead_cohort_community() - community.increase_age_community(self.update_interval_timedelta) + self.forage_community() + self.migrate_community() + self.birth_community() + self.metamorphose_community() + self.metabolize_community( + float(self.data["air_temperature"][0][self.communities.keys()].values), + self.update_interval_timedelta, + ) + self.inflict_non_predation_mortality_community(self.update_interval_timedelta) + self.remove_dead_cohort_community() + self.increase_age_community(self.update_interval_timedelta) # Now that communities have been updated information required to update the # litter model can be extracted @@ -277,19 +303,27 @@ def calculate_litter_additions(self) -> dict[str, DataArray]: # Find the size of all decomposed excrement and carcass pools decomposed_excrement = [ - community.excrement_pool.decomposed_carbon(self.data.grid.cell_area) - for community in self.communities.values() + sum( + excrement_pool.decomposed_carbon(self.data.grid.cell_area) + for excrement_pool in excrement_pools + ) + for excrement_pools in self.excrement_pools.values() ] decomposed_carcasses = [ - community.carcass_pool.decomposed_carbon(self.data.grid.cell_area) - for community in self.communities.values() + sum( + carcass_pool.decomposed_carbon(self.data.grid.cell_area) + for carcass_pool in carcass_pools + ) + for carcass_pools in self.carcass_pools.values() ] # All excrement and carcasses in their respective decomposed subpools are moved # to the litter model, so stored energy of each subpool is reset to zero - for community in self.communities.values(): - community.excrement_pool.decomposed_energy = 0.0 - community.carcass_pool.decomposed_energy = 0.0 + for cell_id in self.communities.keys(): + for excrement_pool in self.excrement_pools[cell_id]: + excrement_pool.decomposed_energy = 0.0 + for carcass_pool in self.carcass_pools[cell_id]: + carcass_pool.decomposed_energy = 0.0 return { "decomposed_excrement": DataArray( @@ -308,16 +342,21 @@ def update_population_densities(self) -> None: """Updates the densities for each functional group in each community.""" for community_id, community in self.communities.items(): - for fg_name, cohorts in community.animal_cohorts.items(): - # Initialize the population density of the functional group - fg_density = 0.0 - for cohort in cohorts: - # Calculate the population density for the cohort - fg_density += self.calculate_density_for_cohort(cohort) - - # Update the corresponding entry in the data variable - # This update should happen once per functional group after summing - # all cohort densities + # Create a dictionary to accumulate densities by functional group + fg_density_dict = {} + + for cohort in community: + fg_name = cohort.functional_group.name + fg_density = self.calculate_density_for_cohort(cohort) + + # Sum the density for the functional group + if fg_name not in fg_density_dict: + fg_density_dict[fg_name] = 0.0 + fg_density_dict[fg_name] += fg_density + + # Update the corresponding entries in the data variable for each + # functional group + for fg_name, fg_density in fg_density_dict.items(): self.data["population_densities"].loc[ {"community_id": community_id, "functional_group_id": fg_name} ] = fg_density @@ -341,3 +380,386 @@ def calculate_density_for_cohort(self, cohort: AnimalCohort) -> float: population_density = cohort.individuals / community_area return population_density + + def abandon_communities(self, cohort: AnimalCohort) -> None: + """Removes the cohort from the occupancy of every community. + + This method is for use in death or re-initializing territories. + + Args: + cohort: The cohort to be removed from the occupancy lists. + """ + for cell_id in cohort.territory.grid_cell_keys: + if cohort.id in self.communities[cell_id]: + self.communities[cell_id].remove(cohort) + + def update_community_occupancy( + self, cohort: AnimalCohort, centroid_key: int + ) -> None: + """This updates the community lists for animal cohort occupancy. + + Args: + cohort: The animal cohort being updates. + centroid_key: The grid cell key of the anchoring grid cell. + """ + + territory_cells = cohort.get_territory_cells(centroid_key) + cohort.territory.update_territory(territory_cells) + + for cell_id in territory_cells: + self.communities[cell_id].add(cohort) + + def populate_community(self) -> None: + """This function creates an instance of each functional group. + + Currently, this is the simplest implementation of populating the animal model. + In each AnimalCommunity one AnimalCohort of each FunctionalGroup type is + generated. So the more functional groups that are made, the denser the animal + community will be. This function will need to be reworked dramatically later on. + + Currently, the number of individuals in a cohort is handled using Damuth's Law, + which only holds for mammals. + + TODO: Move populate_community to following Madingley instead of damuth + + """ + for cell_id, community in self.communities.items(): + for functional_group in self.functional_groups: + individuals = damuths_law( + functional_group.adult_mass, functional_group.damuths_law_terms + ) + + # create a cohort of the functional group + cohort = AnimalCohort( + functional_group, + functional_group.adult_mass, + 0.0, + individuals, + cell_id, + self.grid, + self.model_constants, + ) + # add the cohort to the flat cohort list and the specific community + community.add(cohort) + self.cohorts[cohort.id] = cohort + + def migrate(self, migrant: AnimalCohort, destination_centroid: int) -> None: + """Function to move an AnimalCohort between grid cells. + + This function takes a cohort and a destination grid cell, changes the + centroid of the cohort's territory to be the new cell, and then + reinitializes the territory around the new centroid. + + TODO: travel distance should be a function of body-size or locomotion once + multi-grid occupancy is integrated. + + Args: + migrant: The AnimalCohort moving between AnimalCommunities. + destination_centroid: The grid cell the cohort is moving to. + + """ + + # Remove the cohort from its current community + current_centroid = migrant.centroid_key + self.communities[current_centroid].remove(migrant) + + # Update the cohort's cell ID to the destination cell ID + migrant.centroid_key = destination_centroid + + # Add the cohort to the destination community + self.communities[destination_centroid].add(migrant) + + # Regenerate a territory for the cohort at the destination community + self.abandon_communities(migrant) + self.update_community_occupancy(migrant, destination_centroid) + + def migrate_community(self) -> None: + """This handles migrating all cohorts with a centroid in the community. + + This migration method initiates migration for two reasons: + 1) The cohort is starving and needs to move for a chance at resource access + 2) An initial migration event immediately after birth. + + TODO: MGO - migrate distance mod for larger territories? + + + """ + for cohort in self.cohorts.values(): + is_starving = cohort.is_below_mass_threshold( + self.model_constants.dispersal_mass_threshold + ) + is_juvenile_and_migrate = ( + cohort.age == 0.0 and random() <= cohort.migrate_juvenile_probability() + ) + migrate = is_starving or is_juvenile_and_migrate + + if not migrate: + continue + + # Get the list of neighbors for the current cohort's cell + neighbour_keys = self.data.grid.neighbours[cohort.centroid_key] + + destination_key = choice(neighbour_keys) + self.migrate(cohort, destination_key) + + def remove_dead_cohort(self, cohort: AnimalCohort) -> None: + """Removes an AnimalCohort from the model's cohorts and relevant communities. + + This method removes the cohort from every community listed in its territory's + grid cell keys, and then removes it from the model's main cohort dictionary. + + TODO: this might also need to remove territory objects + + Args: + cohort: The AnimalCohort to be removed. + + Raises: + KeyError: If the cohort ID does not exist in the model's cohorts. + """ + # Check if the cohort exists in self.cohorts + if cohort.id in self.cohorts.values(): + # Iterate over all grid cell keys in the cohort's territory + for cell_id in cohort.territory.grid_cell_keys: + if cell_id in self.communities: + self.communities[cell_id].remove(cohort) + + # Remove the cohort from the model's cohorts dictionary + del self.cohorts[cohort.id] + else: + raise KeyError(f"Cohort with ID {cohort.id} does not exist.") + + def remove_dead_cohort_community(self) -> None: + """This handles remove_dead_cohort for all cohorts in a community.""" + for cohort in self.cohorts.values(): + if cohort.individuals <= 0: + cohort.is_alive = False + self.remove_dead_cohort(cohort) + + def birth(self, parent_cohort: AnimalCohort) -> None: + """Produce a new AnimalCohort through reproduction. + + A cohort can only reproduce if it has an excess of reproductive mass above a + certain threshold. The offspring will be an identical cohort of adults + with age 0 and mass=birth_mass. A new territory, likely smaller b/c allometry, + is generated for the newborn cohort. + + The science here follows Madingley. + + TODO: Check whether Madingley discards excess reproductive mass. + TODO: Rework birth mass for indirect developers. + + Args: + parent_cohort: The AnimalCohort instance which is producing a new cohort. + """ + # semelparous organisms use a portion of their non-reproductive mass to make + # offspring and then they die + non_reproductive_mass_loss = 0.0 + if parent_cohort.functional_group.reproductive_type == "semelparous": + non_reproductive_mass_loss = ( + parent_cohort.mass_current + * parent_cohort.constants.semelparity_mass_loss + ) + parent_cohort.mass_current -= non_reproductive_mass_loss + # kill the semelparous parent cohort + parent_cohort.is_alive = False + + number_offspring = ( + int( + (parent_cohort.reproductive_mass + non_reproductive_mass_loss) + / parent_cohort.functional_group.birth_mass + ) + * parent_cohort.individuals + ) + + # reduce reproductive mass by amount used to generate offspring + parent_cohort.reproductive_mass = 0.0 + + offspring_cohort = AnimalCohort( + parent_cohort.functional_group, + parent_cohort.functional_group.birth_mass, + 0.0, + number_offspring, + parent_cohort.centroid_key, + parent_cohort.grid, + parent_cohort.constants, + ) + + # add a new cohort of the parental type to the community + self.cohorts[offspring_cohort.id] = offspring_cohort + + # add the new cohort to the community lists it occupies + self.update_community_occupancy(offspring_cohort, offspring_cohort.centroid_key) + + if parent_cohort.functional_group.reproductive_type == "semelparous": + self.remove_dead_cohort(parent_cohort) + + def birth_community(self) -> None: + """This handles birth for all cohorts in a community.""" + + # reproduction occurs for cohorts with sufficient reproductive mass + for cohort in self.cohorts.values(): + if ( + not cohort.is_below_mass_threshold( + self.model_constants.birth_mass_threshold + ) + and cohort.functional_group.reproductive_type != "nonreproductive" + ): + self.birth(cohort) + + def forage_community(self) -> None: + """This function organizes the foraging of animal cohorts. + + It loops over every animal cohort in the community and calls the + forage_cohort function with a list of suitable trophic resources. This action + initiates foraging for those resources, with mass transfer details handled + internally by forage_cohort and its helper functions. Future expansions may + include functions for handling scavenging and soil consumption behaviors. + + Cohorts with no remaining individuals post-foraging are marked for death. + + TODO: find a more elegant way to remove dead cohorts between foraging bouts + + """ + + for consumer_cohort in self.cohorts.values(): + # Prepare the prey list for the consumer cohort + if consumer_cohort.territory is None: + raise ValueError("The cohort's territory hasn't been defined.") + prey_list = consumer_cohort.territory.get_prey( + self.communities, consumer_cohort + ) + plant_list = consumer_cohort.territory.get_plant_resources( + self.plant_resources + ) + excrement_list = consumer_cohort.territory.get_excrement_pools( + self.excrement_pools + ) + + # Initiate foraging for the consumer cohort with the prepared resources + consumer_cohort.forage_cohort( + plant_list=plant_list, + animal_list=prey_list, + excrement_pools=excrement_list, + carcass_pools=self.carcass_pools, # the full set of carcass pools + ) + + # temporary solution + self.remove_dead_cohort_community() + + def metabolize_community(self, temperature: float, dt: timedelta64) -> None: + """This handles metabolize for all cohorts in a community. + + This method generates a total amount of metabolic waste per cohort and passes + that waste to handler methods for distinguishing between nitrogenous and + carbonaceous wastes as they need depositing in different pools. This will not + be fully implemented until the stoichiometric rework. + + Respiration wastes are totaled because they are CO2 and not tracked spatially. + Excretion wastes are handled cohort by cohort because they will need to be + spatially explicit with multi-grid occupancy. + + TODO: Rework with stoichiometry + + Args: + temperature: Current air temperature (K). + dt: Number of days over which the metabolic costs should be calculated. + + """ + for cell_id, community in self.communities.items(): + total_carbonaceous_waste = 0.0 + + for cohort in community: + metabolic_waste_mass = cohort.metabolize(temperature, dt) + total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) + cohort.excrete(metabolic_waste_mass, self.excrement_pools[cell_id]) + + # Update the total_animal_respiration for this cell_id. + self.data["total_animal_respiration"].loc[{"cell_id": cell_id}] += ( + total_carbonaceous_waste + ) + + def increase_age_community(self, dt: timedelta64) -> None: + """This handles age for all cohorts in a community. + + Args: + dt: Number of days over which the metabolic costs should be calculated. + + """ + for cohort in self.cohorts.values(): + cohort.increase_age(dt) + + def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: + """This handles natural mortality for all cohorts in a community. + + This includes background mortality, starvation, and, for mature cohorts, + senescence. + + Args: + dt: Number of days over which the metabolic costs should be calculated. + + """ + number_of_days = float(dt / timedelta64(1, "D")) + for cohort in self.cohorts.values(): + cohort.inflict_non_predation_mortality( + number_of_days, cohort.territory.get_carcass_pools(self.carcass_pools) + ) + if cohort.individuals <= 0: + cohort.is_alive = False + self.remove_dead_cohort(cohort) + + def metamorphose(self, larval_cohort: AnimalCohort) -> None: + """This transforms a larval status cohort into an adult status cohort. + + This method takes an indirect developing cohort in its larval form, + inflicts a mortality rate, and creates an adult cohort of the correct type. + + TODO: Build in a relationship between larval_cohort mass and adult cohort mass. + TODO: Is adult_mass the correct mass threshold? + TODO: If the time step drops below a month, this needs an intermediary stage. + + Args: + larval_cohort: The cohort in its larval stage to be transformed. + """ + + # inflict a mortality + number_dead = ceil( + larval_cohort.individuals * larval_cohort.constants.metamorph_mortality + ) + larval_cohort.die_individual( + number_dead, larval_cohort.territory.get_carcass_pools(self.carcass_pools) + ) + # collect the adult functional group + adult_functional_group = get_functional_group_by_name( + self.functional_groups, + larval_cohort.functional_group.offspring_functional_group, + ) + # create the adult cohort + adult_cohort = AnimalCohort( + adult_functional_group, + adult_functional_group.birth_mass, + 0.0, + larval_cohort.individuals, + larval_cohort.centroid_key, + self.grid, + self.model_constants, + ) + + # add a new cohort of the parental type to the community + self.cohorts[adult_cohort.id] = adult_cohort + + # add the new cohort to the community lists it occupies + self.update_community_occupancy(adult_cohort, adult_cohort.centroid_key) + + # remove the larval cohort + larval_cohort.is_alive = False + self.remove_dead_cohort(larval_cohort) + + def metamorphose_community(self) -> None: + """Handle metamorphosis for all applicable cohorts in the community.""" + + for cohort in self.cohorts.values(): + if ( + cohort.functional_group.development_type == DevelopmentType.INDIRECT + and (cohort.mass_current >= cohort.functional_group.adult_mass) + ): + self.metamorphose(cohort) diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index 1b75c302f..e0b9b0ba7 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -1,14 +1,8 @@ """The ''animal'' module provides animal module functionality.""" # noqa: #D205, D415 -from collections.abc import Callable, MutableSequence, Sequence - -from virtual_ecosystem.models.animal.protocols import ( - Community, - Consumer, - DecayPool, - Resource, - Territory, -) +from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort +from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool +from virtual_ecosystem.models.animal.plant_resources import PlantResources class AnimalTerritory: @@ -34,131 +28,162 @@ def __init__( self, centroid: int, grid_cell_keys: list[int], - get_community_by_key: Callable[[int], Community], ) -> None: # The constructor of the AnimalTerritory class. self.centroid = centroid """The centroid community of the territory (not technically a centroid).""" self.grid_cell_keys = grid_cell_keys """A list of grid cells present in the territory.""" - self.get_community_by_key = get_community_by_key - """A list of animal communities present in the territory.""" - # self.territory_prey: Sequence[Consumer] = [] - """A list of animal prey present in the territory.""" - self.territory_plants: Sequence[Resource] = [] - """A list of plant resources present in the territory.""" - self.territory_excrement: Sequence[DecayPool] = [] - """A list of excrement pools present in the territory.""" - self.territory_carcasses: Sequence[DecayPool] = [] - """A list of carcass pools present in the territory.""" - - def update_territory(self) -> None: + + def update_territory(self, new_grid_cell_keys: list[int]) -> None: """Update territory details at initialization and after migration. Args: - consumer_cohort: The AnimalCohort possessing the territory. + new_grid_cell_keys: The new list of grid cell keys the territory occupies. """ - # self.territory_prey = self.get_prey(consumer_cohort) - self.territory_plants = self.get_plant_resources() - self.territory_excrement = self.get_excrement_pools() - self.territory_carcasses = self.get_carcass_pools() + self.grid_cell_keys = new_grid_cell_keys - def abandon_communities(self, consumer_cohort: Consumer) -> None: - """Removes the cohort from the occupancy of every community. + def get_prey( + self, + communities: dict[int, set["AnimalCohort"]], + consumer_cohort: "AnimalCohort", + ) -> list["AnimalCohort"]: + """Collect suitable prey for a given consumer cohort. - This method is for use in death or re-initializing territories. + This method filters suitable prey from the list of animal cohorts across the + territory defined by the cohort's grid cells. Args: - consumer_cohort: The cohort to be removed from the occupancy lists. + communities: A dictionary mapping cell IDs to sets of Consumers + (animal cohorts). + consumer_cohort: The Consumer for which a prey list is being collected. + + Returns: + A sequence of Consumers that can be preyed upon. """ - for cell_id in self.grid_cell_keys: - community = self.get_community_by_key(cell_id) - if ( - consumer_cohort - in community.occupancy[consumer_cohort.functional_group.name] - ): - del community.occupancy[consumer_cohort.functional_group.name][ - consumer_cohort + prey_list: list[AnimalCohort] = [] + + # Iterate over the grid cells in the consumer cohort's territory + for cell_id in consumer_cohort.territory.grid_cell_keys: + potential_prey_cohorts = communities[cell_id] + + # Iterate over each Consumer (potential prey) in the current community + for prey_cohort in potential_prey_cohorts: + # Skip if this cohort is not a prey of the current predator + if prey_cohort.functional_group not in consumer_cohort.prey_groups: + continue + + # Get the size range of the prey this predator eats + min_size, max_size = consumer_cohort.prey_groups[ + prey_cohort.functional_group.name ] - def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: - """Collect suitable prey from all grid cells in the territory. + # Filter the potential prey cohorts based on their size + if ( + min_size <= prey_cohort.mass_current <= max_size + and prey_cohort.individuals != 0 + and prey_cohort is not consumer_cohort + ): + prey_list.append(prey_cohort) - TODO: This is probably not the best way to go about this. Maybe alter collect - prey to take the animal community list instead. Prey is probably too dynamic to - store in this way. + return prey_list + + def get_plant_resources( + self, plant_resources: dict[int, set[PlantResources]] + ) -> list[PlantResources]: + """Returns a list of plant resources in this territory. + + This method checks which grid cells are within this territory + and returns a list of the plant resources available in those grid cells. Args: - consumer_cohort: The AnimalCohort for which a prey list is being collected. + plant_resources: A dictionary of plants where keys are grid cell IDs. Returns: - A list of AnimalCohorts that can be preyed upon. + A list of PlantResources objects in this territory. """ - prey: MutableSequence = [] + plant_resources_in_territory: list[PlantResources] = [] + + # Iterate over all grid cell keys in this territory for cell_id in self.grid_cell_keys: - community = self.get_community_by_key(cell_id) - prey.extend(community.collect_prey(consumer_cohort)) - return prey + # Check if the cell_id is within the provided plant resources + if cell_id in plant_resources: + plant_resources_in_territory.extend(plant_resources[cell_id]) - def get_plant_resources(self) -> MutableSequence[Resource]: - """Collect plant resources from all grid cells in the territory. + return plant_resources_in_territory - TODO: Update internal plant resource generation with a real link to the plant - model. + def get_excrement_pools( + self, excrement_pools: dict[int, set[ExcrementPool]] + ) -> list[ExcrementPool]: + """Returns a list of excrement pools in this territory. - Returns: - A list of PlantResources available in the territory. - """ - plant_resources: MutableSequence = [] - for cell_id in self.grid_cell_keys: - community = self.get_community_by_key(cell_id) - plant_resources.append(community.plant_community) - return plant_resources + This method checks which grid cells are within this territory + and returns a list of the excrement pools available in those grid cells. - def get_excrement_pools(self) -> MutableSequence[DecayPool]: - """Combine excrement pools from all grid cells in the territory. + Args: + excrement_pools: A dictionary of excrement pools where keys are grid + cell IDs. Returns: - A list of ExcrementPools combined from all grid cells. + A list of ExcrementPool objects in this territory. """ - total_excrement: MutableSequence = [] + excrement_pools_in_territory: list[ExcrementPool] = [] + + # Iterate over all grid cell keys in this territory for cell_id in self.grid_cell_keys: - community = self.get_community_by_key(cell_id) - total_excrement.append(community.excrement_pool) - return total_excrement + # 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_carcass_pools(self) -> MutableSequence[DecayPool]: - """Combine carcass pools from all grid cells in the territory. + def get_carcass_pools( + self, carcass_pools: dict[int, set[CarcassPool]] + ) -> list[CarcassPool]: + """Returns a list of carcass pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the carcass pools available in those grid cells. + + Args: + carcass_pools: A dictionary of carcass pools where keys are grid + cell IDs. Returns: - A list of CarcassPools combined from all grid cells. + A list of CarcassPool objects in this territory. """ - total_carcass: MutableSequence = [] + carcass_pools_in_territory: list[CarcassPool] = [] + + # Iterate over all grid cell keys in this territory for cell_id in self.grid_cell_keys: - community = self.get_community_by_key(cell_id) - total_carcass.append(community.carcass_pool) - return total_carcass + # 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 def find_intersecting_carcass_pools( - self, animal_territory: "Territory" - ) -> MutableSequence[DecayPool]: + self, + prey_territory: "AnimalTerritory", + carcass_pools: dict[int, set[CarcassPool]], + ) -> list[CarcassPool]: """Find the carcass pools of the intersection of two territories. Args: - animal_territory: Another AnimalTerritory to find the intersection with. + prey_territory: Another AnimalTerritory to find the intersection with. + carcass_pools: A dictionary mapping cell IDs to CarcassPool objects. Returns: A list of CarcassPools in the intersecting grid cells. """ intersecting_keys = set(self.grid_cell_keys) & set( - animal_territory.grid_cell_keys + prey_territory.grid_cell_keys ) - intersecting_carcass_pools = [] + intersecting_carcass_pools: list[CarcassPool] = [] for cell_id in intersecting_keys: - community = self.get_community_by_key(cell_id) - intersecting_carcass_pools.append(community.carcass_pool) + intersecting_carcass_pools.extend(carcass_pools[cell_id]) return intersecting_carcass_pools diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 28b110b32..4c3d6ecd5 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -7,8 +7,9 @@ from collections.abc import Sequence from virtual_ecosystem.core.data import Data +from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool +from virtual_ecosystem.models.animal.decay import ExcrementPool class PlantResources: @@ -34,6 +35,8 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: # Store the data and extract the appropriate plant data self.data = data """A reference to the core data object.""" + self.cell_id = cell_id + """The community cell containing the plant resources.""" # self.mass_current: float = ( # data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() # ) @@ -56,8 +59,8 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: def get_eaten( self, consumed_mass: float, - herbivore: Consumer, - excrement_pools: Sequence[DecayPool], + herbivore: AnimalCohort, + excrement_pools: Sequence[ExcrementPool], ) -> float: """This function handles herbivory on PlantResources.""" diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 9d157cc0f..64a52338c 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -7,6 +7,7 @@ from typing import Protocol from virtual_ecosystem.core.data import Data +from virtual_ecosystem.models.animal.decay import CarcassPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -38,11 +39,13 @@ class Consumer(Protocol): individuals: int mass_current: float territory: "Territory" + prey_groups: dict[str, tuple[float, float]] def get_eaten( self, potential_consumed_mass: float, predator: "Consumer", + carcass_pools: dict[int, CarcassPool], ) -> float: """The get_eaten method partially defines a consumer.""" ... From 4808ec73dbc72ae3ef586a3aadc28229248b661d Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 28 Aug 2024 14:39:50 +0100 Subject: [PATCH 36/62] Removed the now-defunct AnimalCommunity class. --- .../models/animal/animal_communities.py | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 virtual_ecosystem/models/animal/animal_communities.py diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py deleted file mode 100644 index 3dc0632e2..000000000 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ /dev/null @@ -1,100 +0,0 @@ -"""The ''animal'' module provides animal module functionality.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -from itertools import chain - -from virtual_ecosystem.core.data import Data -from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool -from virtual_ecosystem.models.animal.functional_group import ( - FunctionalGroup, -) -from virtual_ecosystem.models.animal.plant_resources import PlantResources - - -class AnimalCommunity: - """This is a class for the animal community of a grid cell. - - This class manages the animal cohorts present in a grid cell and provides methods - that need to loop over all cohorts, move cohorts to new grids, or manage an - interaction between two cohorts. - - Args: - functional_groups: A list of FunctionalGroup objects - data: The core data object - community_key: The integer key of the cell id for this community - neighbouring_keys: A list of cell id keys for neighbouring communities - get_community_by_key: A function to return a designated AnimalCommunity by - integer key. - """ - - def __init__( - self, - functional_groups: list[FunctionalGroup], - data: Data, - community_key: int, - neighbouring_keys: list[int], - get_community_by_key: Callable[[int], AnimalCommunity], - constants: AnimalConsts = AnimalConsts(), - ) -> None: - # The constructor of the AnimalCommunity class. - self.data = data - """A reference to the core data object.""" - self.functional_groups = tuple(functional_groups) - """A list of all FunctionalGroup types in the model.""" - self.community_key = community_key - """Integer designation of the community in the model grid.""" - self.neighbouring_keys = neighbouring_keys - """List of integer keys of neighbouring communities.""" - self.get_community_by_key = get_community_by_key - """Callable get_community_by_key from AnimalModel.""" - self.constants = constants - """Animal constants.""" - self.animal_cohorts: dict[str, list[AnimalCohort]] = { - k.name: [] for k in self.functional_groups - } - """A dictionary of lists of animal cohorts keyed by functional group, containing - only those cohorts having their territory centroid in this community.""" - self.occupancy: dict[str, dict[AnimalCohort, float]] = { - k.name: {} for k in self.functional_groups - } - """A dictionary of dictionaries of animal cohorts keyed by functional group and - cohort, with the value being the occupancy percentage.""" - self.carcass_pool: CarcassPool = CarcassPool(10000.0, 0.0) - """A pool for animal carcasses within the community.""" - self.excrement_pool: ExcrementPool = ExcrementPool(10000.0, 0.0) - """A pool for excrement within the community.""" - self.plant_community: PlantResources = PlantResources( - data=self.data, - cell_id=self.community_key, - constants=self.constants, - ) - - @property - def all_animal_cohorts(self) -> Iterable[AnimalCohort]: - """Get an iterable of all animal cohorts w/ proportion in the community. - - This property provides access to all the animal cohorts contained - within this community class. - - Returns: - Iterable[AnimalCohort]: An iterable of AnimalCohort objects. - """ - return chain.from_iterable(self.animal_cohorts.values()) - - @property - def all_occupying_cohorts(self) -> Iterable[AnimalCohort]: - """Get an iterable of all occupying cohorts w/ proportion in the community. - - This property provides access to all the animal cohorts contained - within this community class. - - Returns: - Iterable[AnimalCohort]: An iterable of AnimalCohort objects. - """ - return chain.from_iterable( - cohort_dict.keys() for cohort_dict in self.occupancy.values() - ) From 83cb8518dc7f9af659f3b7db15a63d83059bedfa Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 29 Aug 2024 15:57:39 +0100 Subject: [PATCH 37/62] Replaced set with list for tracking pools and cohorts. --- .../models/animal/animal_cohorts.py | 10 +++--- .../models/animal/animal_model.py | 36 +++++++++---------- .../models/animal/animal_territories.py | 10 +++--- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index d96dd5b58..f8ca338ce 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -172,7 +172,9 @@ def metabolize(self, temperature: float, dt: timedelta64) -> float: # in data object return actual_mass_metabolized * self.individuals - def excrete(self, excreta_mass: float, excrement_pools: set[ExcrementPool]) -> None: + def excrete( + self, excreta_mass: float, excrement_pools: list[ExcrementPool] + ) -> None: """Transfers nitrogenous metabolic wastes to the excrement pool. This method will not be fully implemented until the stoichiometric rework. All @@ -343,7 +345,7 @@ def get_eaten( self, potential_consumed_mass: float, predator: AnimalCohort, - carcass_pools: dict[int, set[CarcassPool]], + carcass_pools: dict[int, list[CarcassPool]], ) -> float: """Removes individuals according to mass demands of a predation event. @@ -644,7 +646,7 @@ def delta_mass_predation( self, animal_list: list[AnimalCohort], excrement_pools: list[ExcrementPool], - carcass_pools: dict[int, set[CarcassPool]], + carcass_pools: dict[int, list[CarcassPool]], ) -> float: """This method handles mass assimilation from predation. @@ -745,7 +747,7 @@ def forage_cohort( plant_list: list[PlantResources], animal_list: list[AnimalCohort], excrement_pools: list[ExcrementPool], - carcass_pools: dict[int, set[CarcassPool]], + carcass_pools: dict[int, list[CarcassPool]], ) -> None: """This function handles selection of resources from a list for consumption. diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index daab8cdda..d7b809155 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -92,34 +92,34 @@ def __init__( """List of functional groups in the model.""" self.model_constants = model_constants """Animal constants.""" - self.plant_resources: dict[int, set[PlantResources]] = { - cell_id: { + self.plant_resources: dict[int, list[PlantResources]] = { + cell_id: [ PlantResources( data=self.data, cell_id=cell_id, constants=self.model_constants ) - } + ] for cell_id in self.data.grid.cell_id } """The plant resource pools in the model with associated grid cell ids.""" - self.excrement_pools: dict[int, set[ExcrementPool]] = { - cell_id: { + self.excrement_pools: dict[int, list[ExcrementPool]] = { + cell_id: [ ExcrementPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) - } + ] for cell_id in self.data.grid.cell_id } """The excrement pools in the model with associated grid cell ids.""" - self.carcass_pools: dict[int, set[CarcassPool]] = { - cell_id: { + self.carcass_pools: dict[int, list[CarcassPool]] = { + cell_id: [ CarcassPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) - } + ] for cell_id in self.data.grid.cell_id } """The carcass pools in the model with associated grid cell ids.""" self.cohorts: dict[UUID, AnimalCohort] = {} """A dictionary of all animal cohorts and their unique ids.""" - self.communities: dict[int, set[AnimalCohort]] = { - cell_id: set() for cell_id in self.data.grid.cell_id + self.communities: dict[int, list[AnimalCohort]] = { + cell_id: list() for cell_id in self.data.grid.cell_id } """The animal cohorts organized by cell_id.""" self._initialize_communities(functional_groups) @@ -145,9 +145,9 @@ def _initialize_communities(self, functional_groups: list[FunctionalGroup]) -> N functional_groups: The list of functional groups that will populate the model. """ - # Initialize communities dictionary with cell IDs as keys and empty sets for + # Initialize communities dictionary with cell IDs as keys and empty lists for # cohorts - self.communities = {cell_id: set() for cell_id in self.data.grid.cell_id} + self.communities = {cell_id: list() for cell_id in self.data.grid.cell_id} # Iterate over each cell and functional group to create and populate cohorts for cell_id in self.data.grid.cell_id: @@ -168,7 +168,7 @@ def _initialize_communities(self, functional_groups: list[FunctionalGroup]) -> N constants=self.model_constants, ) self.cohorts[cohort.id] = cohort - self.communities[cell_id].add(cohort) + self.communities[cell_id].append(cohort) @classmethod def from_config( @@ -407,7 +407,7 @@ def update_community_occupancy( cohort.territory.update_territory(territory_cells) for cell_id in territory_cells: - self.communities[cell_id].add(cohort) + self.communities[cell_id].append(cohort) def populate_community(self) -> None: """This function creates an instance of each functional group. @@ -440,7 +440,7 @@ def populate_community(self) -> None: self.model_constants, ) # add the cohort to the flat cohort list and the specific community - community.add(cohort) + community.append(cohort) self.cohorts[cohort.id] = cohort def migrate(self, migrant: AnimalCohort, destination_centroid: int) -> None: @@ -467,7 +467,7 @@ def migrate(self, migrant: AnimalCohort, destination_centroid: int) -> None: migrant.centroid_key = destination_centroid # Add the cohort to the destination community - self.communities[destination_centroid].add(migrant) + self.communities[destination_centroid].append(migrant) # Regenerate a territory for the cohort at the destination community self.abandon_communities(migrant) @@ -640,7 +640,7 @@ def forage_community(self) -> None: plant_list=plant_list, animal_list=prey_list, excrement_pools=excrement_list, - carcass_pools=self.carcass_pools, # the full set of carcass pools + carcass_pools=self.carcass_pools, # the full list of carcass pools ) # temporary solution diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py index e0b9b0ba7..2d928eb65 100644 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ b/virtual_ecosystem/models/animal/animal_territories.py @@ -47,7 +47,7 @@ def update_territory(self, new_grid_cell_keys: list[int]) -> None: def get_prey( self, - communities: dict[int, set["AnimalCohort"]], + communities: dict[int, list["AnimalCohort"]], consumer_cohort: "AnimalCohort", ) -> list["AnimalCohort"]: """Collect suitable prey for a given consumer cohort. @@ -91,7 +91,7 @@ def get_prey( return prey_list def get_plant_resources( - self, plant_resources: dict[int, set[PlantResources]] + self, plant_resources: dict[int, list[PlantResources]] ) -> list[PlantResources]: """Returns a list of plant resources in this territory. @@ -115,7 +115,7 @@ def get_plant_resources( return plant_resources_in_territory def get_excrement_pools( - self, excrement_pools: dict[int, set[ExcrementPool]] + self, excrement_pools: dict[int, list[ExcrementPool]] ) -> list[ExcrementPool]: """Returns a list of excrement pools in this territory. @@ -140,7 +140,7 @@ def get_excrement_pools( return excrement_pools_in_territory def get_carcass_pools( - self, carcass_pools: dict[int, set[CarcassPool]] + self, carcass_pools: dict[int, list[CarcassPool]] ) -> list[CarcassPool]: """Returns a list of carcass pools in this territory. @@ -167,7 +167,7 @@ def get_carcass_pools( def find_intersecting_carcass_pools( self, prey_territory: "AnimalTerritory", - carcass_pools: dict[int, set[CarcassPool]], + carcass_pools: dict[int, list[CarcassPool]], ) -> list[CarcassPool]: """Find the carcass pools of the intersection of two territories. From 867536fe6bc23e77c5649744a4afc5811cf948d2 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 17 Sep 2024 11:14:10 +0100 Subject: [PATCH 38/62] Small updates to conftest and territories. --- tests/models/animals/conftest.py | 10 +++------- .../models/animals/test_animal_territories.py | 19 ++++++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index f8b4ff44b..c0125210f 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -342,15 +342,11 @@ def _get_community_by_key(key): @pytest.fixture -def animal_territory_instance(get_community_by_key): - """Fixture for animal territories.""" +def animal_territory_instance(): + """Fixture to create an AnimalTerritory instance.""" from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory - return AnimalTerritory( - centroid=0, - grid_cell_keys=[1, 2, 3], - get_community_by_key=get_community_by_key, - ) + return AnimalTerritory(centroid=1, grid_cell_keys=[1, 2, 3]) @pytest.fixture diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py index c839632c4..2aba829fd 100644 --- a/tests/models/animals/test_animal_territories.py +++ b/tests/models/animals/test_animal_territories.py @@ -27,19 +27,16 @@ def mock_get_carcass_pools(self, mocker, animal_territory_instance): animal_territory_instance, "get_carcass_pools", return_value=[] ) - def test_update_territory( - self, - animal_territory_instance, - mock_get_plant_resources, - mock_get_excrement_pools, - mock_get_carcass_pools, - ): + def test_update_territory(self, animal_territory_instance): """Test for update_territory method.""" - animal_territory_instance.update_territory() + # Define new grid cell keys for updating the territory + new_grid_cell_keys = [4, 5, 6] + + # Call update_territory with new grid cell keys + animal_territory_instance.update_territory(new_grid_cell_keys) - mock_get_plant_resources.assert_called_once() - mock_get_excrement_pools.assert_called_once() - mock_get_carcass_pools.assert_called_once() + # Check if the territory was updated correctly + assert animal_territory_instance.grid_cell_keys == new_grid_cell_keys def test_get_prey( self, From 4af4eb67f80e7da02dd25afa985ba3480905f785 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 18 Sep 2024 16:11:12 +0100 Subject: [PATCH 39/62] Removed animalterritories and protocols, moved functionality into animal cohorts. --- .../models/animal/animal_cohorts.py | 168 +++++++++++- .../models/animal/animal_model.py | 22 +- .../models/animal/animal_territories.py | 248 ------------------ virtual_ecosystem/models/animal/protocols.py | 116 -------- .../models/animal/scaling_functions.py | 62 +++++ 5 files changed, 227 insertions(+), 389 deletions(-) delete mode 100644 virtual_ecosystem/models/animal/animal_territories.py delete mode 100644 virtual_ecosystem/models/animal/protocols.py diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index f8ca338ce..1cbb65289 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -10,10 +10,6 @@ import virtual_ecosystem.models.animal.scaling_functions as sf from virtual_ecosystem.core.grid import Grid from virtual_ecosystem.core.logger import LOGGER -from virtual_ecosystem.models.animal.animal_territories import ( - AnimalTerritory, - bfs_territory, -) from virtual_ecosystem.models.animal.animal_traits import DietType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool @@ -86,6 +82,9 @@ def __init__( self.occupancy_proportion: float = 1.0 / self.territory_size """The proportion of the cohort that is within a territorial given grid cell.""" self._initialize_territory(centroid_key) + """Initialize the territory using the centroid grid key.""" + self.territory: list[int] + """The list of grid cells currently occupied by the cohort.""" # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement @@ -96,16 +95,18 @@ def __init__( def get_territory_cells(self, centroid_key: int) -> list[int]: """This calls bfs_territory to determine the scope of the territory. + TODO: local import of bfs_territory is temporary while deciding whether to keep + animal_territory.py + Args: centroid_key: The central grid cell key of the territory. """ - # Each grid cell is 1 hectare, territory size in grids is the same as hectares target_cell_number = int(self.territory_size) # Perform BFS to determine the territory cells - territory_cells = bfs_territory( + territory_cells = sf.bfs_territory( centroid_key, target_cell_number, self.grid.cell_nx, @@ -120,15 +121,24 @@ def _initialize_territory( ) -> None: """This initializes the territory occupied by the cohort. + TODO: local import of AnimalTerritory is temporary while deciding whether to + keep the class + Args: centroid_key: The grid cell key anchoring the territory. - get_community_by_key: The method for accessing animal communities by key. """ - territory_cells = self.get_territory_cells(centroid_key) + self.territory = self.get_territory_cells(centroid_key) + + def update_territory(self, new_grid_cell_keys: list[int]) -> None: + """Update territory details at initialization and after migration. + + Args: + new_grid_cell_keys: The new list of grid cell keys the territory occupies. + + """ - # Generate the territory - self.territory = AnimalTerritory(centroid_key, territory_cells) + self.territory = new_grid_cell_keys def metabolize(self, temperature: float, dt: timedelta64) -> float: """The function to reduce body mass through metabolism. @@ -388,7 +398,7 @@ def get_eaten( self.is_alive = False # Find the intersection of prey and predator territories - intersection_carcass_pools = self.territory.find_intersecting_carcass_pools( + intersection_carcass_pools = self.find_intersecting_carcass_pools( predator.territory, carcass_pools ) @@ -948,3 +958,139 @@ def inflict_non_predation_mortality( # Remove the dead individuals from the cohort self.die_individual(number_dead, carcass_pools) + + def get_prey( + self, + communities: dict[int, list[AnimalCohort]], + ) -> list[AnimalCohort]: + """Collect suitable prey for a given consumer cohort. + + This method filters suitable prey from the list of animal cohorts across the + territory defined by the cohort's grid cells. + + Args: + communities: A dictionary mapping cell IDs to sets of Consumers + (animal cohorts). + consumer_cohort: The Consumer for which a prey list is being collected. + + Returns: + A sequence of Consumers that can be preyed upon. + """ + prey_list: list = [] + + # Iterate over the grid cells in the consumer cohort's territory + for cell_id in self.territory: + potential_prey_cohorts = communities[cell_id] + + # Iterate over each Consumer (potential prey) in the current community + for prey_cohort in potential_prey_cohorts: + # Skip if this cohort is not a prey of the current predator + if prey_cohort.functional_group not in self.prey_groups: + continue + + # Get the size range of the prey this predator eats + min_size, max_size = self.prey_groups[prey_cohort.functional_group.name] + + # Filter the potential prey cohorts based on their size + if ( + min_size <= prey_cohort.mass_current <= max_size + and prey_cohort.individuals != 0 + and prey_cohort is not self + ): + prey_list.append(prey_cohort) + + return prey_list + + def get_plant_resources( + self, plant_resources: dict[int, list[PlantResources]] + ) -> list[PlantResources]: + """Returns a list of plant resources in this territory. + + This method checks which grid cells are within this territory + and returns a list of the plant resources available in those grid cells. + + Args: + plant_resources: A dictionary of plants where keys are grid cell IDs. + + Returns: + A list of PlantResources objects in this territory. + """ + plant_resources_in_territory: list = [] + + # 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: + plant_resources_in_territory.extend(plant_resources[cell_id]) + + return plant_resources_in_territory + + def get_excrement_pools( + self, excrement_pools: dict[int, list[ExcrementPool]] + ) -> list[ExcrementPool]: + """Returns a list of excrement pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the excrement pools available in those grid cells. + + Args: + excrement_pools: A dictionary of excrement pools where keys are grid + cell IDs. + + Returns: + A list of ExcrementPool objects in this territory. + """ + excrement_pools_in_territory: list[ExcrementPool] = [] + + # 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_carcass_pools( + self, carcass_pools: dict[int, list[CarcassPool]] + ) -> list[CarcassPool]: + """Returns a list of carcass pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the carcass pools available in those grid cells. + + Args: + carcass_pools: A dictionary of carcass pools where keys are grid + cell IDs. + + Returns: + A list of CarcassPool objects in this 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 + + def find_intersecting_carcass_pools( + self, + prey_territory: list[int], + carcass_pools: dict[int, list[CarcassPool]], + ) -> list[CarcassPool]: + """Find the carcass pools of the intersection of two territories. + + Args: + prey_territory: Another AnimalTerritory to find the intersection with. + carcass_pools: A dictionary mapping cell IDs to CarcassPool objects. + + Returns: + A list of CarcassPools in the intersecting grid cells. + """ + intersecting_keys = set(self.territory) & set(prey_territory) + intersecting_carcass_pools: list[CarcassPool] = [] + for cell_id in intersecting_keys: + intersecting_carcass_pools.extend(carcass_pools[cell_id]) + return intersecting_carcass_pools diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index d7b809155..0986106f6 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -389,7 +389,7 @@ def abandon_communities(self, cohort: AnimalCohort) -> None: Args: cohort: The cohort to be removed from the occupancy lists. """ - for cell_id in cohort.territory.grid_cell_keys: + for cell_id in cohort.territory: if cohort.id in self.communities[cell_id]: self.communities[cell_id].remove(cohort) @@ -404,7 +404,7 @@ def update_community_occupancy( """ territory_cells = cohort.get_territory_cells(centroid_key) - cohort.territory.update_territory(territory_cells) + cohort.update_territory(territory_cells) for cell_id in territory_cells: self.communities[cell_id].append(cohort) @@ -519,7 +519,7 @@ def remove_dead_cohort(self, cohort: AnimalCohort) -> None: # Check if the cohort exists in self.cohorts if cohort.id in self.cohorts.values(): # Iterate over all grid cell keys in the cohort's territory - for cell_id in cohort.territory.grid_cell_keys: + for cell_id in cohort.territory: if cell_id in self.communities: self.communities[cell_id].remove(cohort) @@ -625,15 +625,9 @@ def forage_community(self) -> None: # Prepare the prey list for the consumer cohort if consumer_cohort.territory is None: raise ValueError("The cohort's territory hasn't been defined.") - prey_list = consumer_cohort.territory.get_prey( - self.communities, consumer_cohort - ) - plant_list = consumer_cohort.territory.get_plant_resources( - self.plant_resources - ) - excrement_list = consumer_cohort.territory.get_excrement_pools( - self.excrement_pools - ) + prey_list = consumer_cohort.get_prey(self.communities) + plant_list = consumer_cohort.get_plant_resources(self.plant_resources) + excrement_list = consumer_cohort.get_excrement_pools(self.excrement_pools) # Initiate foraging for the consumer cohort with the prepared resources consumer_cohort.forage_cohort( @@ -701,7 +695,7 @@ def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: number_of_days = float(dt / timedelta64(1, "D")) for cohort in self.cohorts.values(): cohort.inflict_non_predation_mortality( - number_of_days, cohort.territory.get_carcass_pools(self.carcass_pools) + number_of_days, cohort.get_carcass_pools(self.carcass_pools) ) if cohort.individuals <= 0: cohort.is_alive = False @@ -726,7 +720,7 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: larval_cohort.individuals * larval_cohort.constants.metamorph_mortality ) larval_cohort.die_individual( - number_dead, larval_cohort.territory.get_carcass_pools(self.carcass_pools) + number_dead, larval_cohort.get_carcass_pools(self.carcass_pools) ) # collect the adult functional group adult_functional_group = get_functional_group_by_name( diff --git a/virtual_ecosystem/models/animal/animal_territories.py b/virtual_ecosystem/models/animal/animal_territories.py deleted file mode 100644 index 2d928eb65..000000000 --- a/virtual_ecosystem/models/animal/animal_territories.py +++ /dev/null @@ -1,248 +0,0 @@ -"""The ''animal'' module provides animal module functionality.""" # noqa: #D205, D415 - -from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool -from virtual_ecosystem.models.animal.plant_resources import PlantResources - - -class AnimalTerritory: - """This class defines a territory occupied by an animal cohort. - - The purpose of this class is to function as an intermediary between cohorts - and the plants, pools, and prey of the grid cells that the cohort occupies. It - should have a total area, a list of the specific grid cells within it, and lists of - the plants, pools, and prey. - - The key assumption is that an animal cohort is equally distributed across its - territory for the time-step. - - - - Args: - grid_cell_keys: A list of grid cell ids that make up the territory. - get_community_by_key: A function to return an AnimalCommunity for a given - integer key. - """ - - def __init__( - self, - centroid: int, - grid_cell_keys: list[int], - ) -> None: - # The constructor of the AnimalTerritory class. - self.centroid = centroid - """The centroid community of the territory (not technically a centroid).""" - self.grid_cell_keys = grid_cell_keys - """A list of grid cells present in the territory.""" - - def update_territory(self, new_grid_cell_keys: list[int]) -> None: - """Update territory details at initialization and after migration. - - Args: - new_grid_cell_keys: The new list of grid cell keys the territory occupies. - - """ - - self.grid_cell_keys = new_grid_cell_keys - - def get_prey( - self, - communities: dict[int, list["AnimalCohort"]], - consumer_cohort: "AnimalCohort", - ) -> list["AnimalCohort"]: - """Collect suitable prey for a given consumer cohort. - - This method filters suitable prey from the list of animal cohorts across the - territory defined by the cohort's grid cells. - - Args: - communities: A dictionary mapping cell IDs to sets of Consumers - (animal cohorts). - consumer_cohort: The Consumer for which a prey list is being collected. - - Returns: - A sequence of Consumers that can be preyed upon. - """ - prey_list: list[AnimalCohort] = [] - - # Iterate over the grid cells in the consumer cohort's territory - for cell_id in consumer_cohort.territory.grid_cell_keys: - potential_prey_cohorts = communities[cell_id] - - # Iterate over each Consumer (potential prey) in the current community - for prey_cohort in potential_prey_cohorts: - # Skip if this cohort is not a prey of the current predator - if prey_cohort.functional_group not in consumer_cohort.prey_groups: - continue - - # Get the size range of the prey this predator eats - min_size, max_size = consumer_cohort.prey_groups[ - prey_cohort.functional_group.name - ] - - # Filter the potential prey cohorts based on their size - if ( - min_size <= prey_cohort.mass_current <= max_size - and prey_cohort.individuals != 0 - and prey_cohort is not consumer_cohort - ): - prey_list.append(prey_cohort) - - return prey_list - - def get_plant_resources( - self, plant_resources: dict[int, list[PlantResources]] - ) -> list[PlantResources]: - """Returns a list of plant resources in this territory. - - This method checks which grid cells are within this territory - and returns a list of the plant resources available in those grid cells. - - Args: - plant_resources: A dictionary of plants where keys are grid cell IDs. - - Returns: - A list of PlantResources objects in this territory. - """ - plant_resources_in_territory: list[PlantResources] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.grid_cell_keys: - # Check if the cell_id is within the provided plant resources - if cell_id in plant_resources: - plant_resources_in_territory.extend(plant_resources[cell_id]) - - return plant_resources_in_territory - - def get_excrement_pools( - self, excrement_pools: dict[int, list[ExcrementPool]] - ) -> list[ExcrementPool]: - """Returns a list of excrement pools in this territory. - - This method checks which grid cells are within this territory - and returns a list of the excrement pools available in those grid cells. - - Args: - excrement_pools: A dictionary of excrement pools where keys are grid - cell IDs. - - Returns: - A list of ExcrementPool objects in this territory. - """ - excrement_pools_in_territory: list[ExcrementPool] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.grid_cell_keys: - # 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_carcass_pools( - self, carcass_pools: dict[int, list[CarcassPool]] - ) -> list[CarcassPool]: - """Returns a list of carcass pools in this territory. - - This method checks which grid cells are within this territory - and returns a list of the carcass pools available in those grid cells. - - Args: - carcass_pools: A dictionary of carcass pools where keys are grid - cell IDs. - - Returns: - A list of CarcassPool objects in this territory. - """ - carcass_pools_in_territory: list[CarcassPool] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.grid_cell_keys: - # 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 - - def find_intersecting_carcass_pools( - self, - prey_territory: "AnimalTerritory", - carcass_pools: dict[int, list[CarcassPool]], - ) -> list[CarcassPool]: - """Find the carcass pools of the intersection of two territories. - - Args: - prey_territory: Another AnimalTerritory to find the intersection with. - carcass_pools: A dictionary mapping cell IDs to CarcassPool objects. - - Returns: - A list of CarcassPools in the intersecting grid cells. - """ - intersecting_keys = set(self.grid_cell_keys) & set( - prey_territory.grid_cell_keys - ) - intersecting_carcass_pools: list[CarcassPool] = [] - for cell_id in intersecting_keys: - intersecting_carcass_pools.extend(carcass_pools[cell_id]) - return intersecting_carcass_pools - - -def bfs_territory( - centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int -) -> list[int]: - """Performs breadth-first search (BFS) to generate a list of territory cells. - - BFS does some slightly weird stuff on a grid of squares but behaves properly on a - graph. As we are talking about moving to a graph anyway, I can leave it like this - and make adjustments for diagonals if we decide to stay with squares/cells. - - TODO: Revise for diagonals if we stay on grid squares/cells. - TODO: might be able to save time with an ifelse for small territories - - Args: - centroid_key: The community key anchoring the territory. - target_cell_number: The number of grid cells in the territory. - cell_nx: Number of cells along the x-axis. - cell_ny: Number of cells along the y-axis. - - Returns: - A list of grid cell keys representing the territory. - """ - - # Convert centroid key to row and column indices - row, col = divmod(centroid_key, cell_nx) - - # Initialize the territory cells list with the centroid key - territory_cells = [centroid_key] - - # Define the possible directions for BFS traversal: Up, Down, Left, Right - directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] - - # Set to keep track of visited cells to avoid revisiting - visited = set(territory_cells) - - # Queue for BFS, initialized with the starting position (row, col) - queue = [(row, col)] - - # Perform BFS until the queue is empty or we reach the target number of cells - while queue and len(territory_cells) < target_cell_number: - # Dequeue the next cell to process - r, c = queue.pop(0) - - # Explore all neighboring cells in the defined directions - for dr, dc in directions: - nr, nc = r + dr, c + dc - # Check if the new cell is within grid bounds - if 0 <= nr < cell_ny and 0 <= nc < cell_nx: - new_cell = nr * cell_nx + nc - # If the cell hasn't been visited, mark it as visited and add to the - # territory - if new_cell not in visited: - visited.add(new_cell) - territory_cells.append(new_cell) - queue.append((nr, nc)) - # If we have reached the target number of cells, exit the loop - if len(territory_cells) >= target_cell_number: - break - - return territory_cells diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py deleted file mode 100644 index 64a52338c..000000000 --- a/virtual_ecosystem/models/animal/protocols.py +++ /dev/null @@ -1,116 +0,0 @@ -"""The `models.animal.protocols` module contains a class provides eatability definition -used by AnimalCohorts, PlantResources, and Carcasses in the -:mod:`~virtual_ecosystem.models.animal` module. -""" # noqa: D205 - -from collections.abc import MutableSequence, Sequence -from typing import Protocol - -from virtual_ecosystem.core.data import Data -from virtual_ecosystem.models.animal.decay import CarcassPool -from virtual_ecosystem.models.animal.functional_group import FunctionalGroup - - -class Community(Protocol): - """This is the protocol for defining communities.""" - - functional_groups: list[FunctionalGroup] - data: Data - community_key: int - neighbouring_keys: list[int] - carcass_pool: "DecayPool" - excrement_pool: "DecayPool" - plant_community: "Resource" - occupancy: dict[str, dict["Consumer", float]] - - def get_community_by_key(self, key: int) -> "Community": - """Method to return a designated Community by integer key.""" - ... - - def collect_prey(self, consumer_cohort: "Consumer") -> MutableSequence["Consumer"]: - """Method to return a list of prey cohorts.""" - ... - - -class Consumer(Protocol): - """This is the protocol for defining consumers (currently just AnimalCohort).""" - - functional_group: FunctionalGroup - individuals: int - mass_current: float - territory: "Territory" - prey_groups: dict[str, tuple[float, float]] - - def get_eaten( - self, - potential_consumed_mass: float, - predator: "Consumer", - carcass_pools: dict[int, CarcassPool], - ) -> float: - """The get_eaten method partially defines a consumer.""" - ... - - -class Pool(Protocol): - """This is a protocol for defining dummy abiotic pools containing energy.""" - - mass_current: float - - -class DecayPool(Protocol): - """Defines biotic pools containing both accessible and inaccessible energy.""" - - scavengeable_energy: float - - decomposed_energy: float - - -class Resource(Protocol): - """This is the protocol for defining what classes work as trophic resources.""" - - mass_current: float - - def get_eaten( - self, consumed_mass: float, consumer: Consumer, pools: Sequence[DecayPool] - ) -> float: - """The get_eaten method defines a resource.""" - ... - - -class Territory(Protocol): - """This is the protocol for defining territories. - - Currently, this is an intermediary to prevent circular reference between territories - and cohorts. - - """ - - grid_cell_keys: Sequence[int] - territory_carcasses: Sequence[DecayPool] - territory_excrement: Sequence[DecayPool] - - def get_prey(self, consumer_cohort: Consumer) -> MutableSequence[Consumer]: - """The get_prey method partially defines a territory.""" - ... - - def get_plant_resources(self) -> MutableSequence[Resource]: - """The get_prey method partially defines a territory.""" - ... - - def get_excrement_pools(self) -> MutableSequence[DecayPool]: - """The get_prey method partially defines a territory.""" - ... - - def get_carcass_pools(self) -> MutableSequence[DecayPool]: - """The get_prey method partially defines a territory.""" - ... - - def find_intersecting_carcass_pools( - self, animal_territory: "Territory" - ) -> MutableSequence[DecayPool]: - """The find_intersecting_carcass_pools method partially defines a territory.""" - ... - - def abandon_communities(self, consumer_cohort: Consumer) -> None: - """The abandon_communities method partially defines a territory.""" - ... diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index 53fbb522e..4a1ea1ac4 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -469,3 +469,65 @@ def territory_size(mass: float) -> float: territory = 30.0 return territory + + +def bfs_territory( + centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int +) -> list[int]: + """Performs breadth-first search (BFS) to generate a list of territory cells. + + BFS does some slightly weird stuff on a grid of squares but behaves properly on a + graph. As we are talking about moving to a graph anyway, I can leave it like this + and make adjustments for diagonals if we decide to stay with squares/cells. + + TODO: Revise for diagonals if we stay on grid squares/cells. + TODO: might be able to save time with an ifelse for small territories + TODO: scaling territories is a temporary home while i rework territories + + Args: + centroid_key: The community key anchoring the territory. + target_cell_number: The number of grid cells in the territory. + cell_nx: Number of cells along the x-axis. + cell_ny: Number of cells along the y-axis. + + Returns: + A list of grid cell keys representing the territory. + """ + + # Convert centroid key to row and column indices + row, col = divmod(centroid_key, cell_nx) + + # Initialize the territory cells list with the centroid key + territory_cells = [centroid_key] + + # Define the possible directions for BFS traversal: Up, Down, Left, Right + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + # Set to keep track of visited cells to avoid revisiting + visited = set(territory_cells) + + # Queue for BFS, initialized with the starting position (row, col) + queue = [(row, col)] + + # Perform BFS until the queue is empty or we reach the target number of cells + while queue and len(territory_cells) < target_cell_number: + # Dequeue the next cell to process + r, c = queue.pop(0) + + # Explore all neighboring cells in the defined directions + for dr, dc in directions: + nr, nc = r + dr, c + dc + # Check if the new cell is within grid bounds + if 0 <= nr < cell_ny and 0 <= nc < cell_nx: + new_cell = nr * cell_nx + nc + # If the cell hasn't been visited, mark it as visited and add to the + # territory + if new_cell not in visited: + visited.add(new_cell) + territory_cells.append(new_cell) + queue.append((nr, nc)) + # If we have reached the target number of cells, exit the loop + if len(territory_cells) >= target_cell_number: + break + + return territory_cells From 4804a01db277b54654702b8361910a2a5dc05521 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 18 Sep 2024 16:27:31 +0100 Subject: [PATCH 40/62] Turns out I needed those protocols. --- .../models/animal/animal_cohorts.py | 22 ++++---- .../models/animal/animal_model.py | 3 +- .../models/animal/plant_resources.py | 4 +- virtual_ecosystem/models/animal/protocols.py | 50 +++++++++++++++++++ 4 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 virtual_ecosystem/models/animal/protocols.py diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 1cbb65289..38e44beb7 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -14,7 +14,7 @@ from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup -from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.protocols import Resource class AnimalCohort: @@ -423,7 +423,7 @@ def calculate_alpha(self) -> float: return sf.alpha_i_k(self.constants.alpha_0_herb, self.mass_current) def calculate_potential_consumed_biomass( - self, target_plant: PlantResources, alpha: float + self, target_plant: Resource, alpha: float ) -> float: """Calculate potential consumed biomass for the target plant. @@ -447,7 +447,7 @@ def calculate_potential_consumed_biomass( return sf.k_i_k(alpha, phi, target_plant.mass_current, A_cell) def calculate_total_handling_time_for_herbivory( - self, plant_list: list[PlantResources], alpha: float + self, plant_list: list[Resource], alpha: float ) -> float: """Calculate total handling time across all plant resources. @@ -481,9 +481,7 @@ def calculate_total_handling_time_for_herbivory( for plant in plant_list ) - def F_i_k( - self, plant_list: list[PlantResources], target_plant: PlantResources - ) -> float: + def F_i_k(self, plant_list: list[Resource], target_plant: Resource) -> float: """Method to determine instantaneous herbivory rate on plant k. This method integrates the calculated search efficiency, potential consumed @@ -693,7 +691,7 @@ def delta_mass_predation( return total_consumed_mass def calculate_consumed_mass_herbivory( - self, plant_list: list[PlantResources], target_plant: PlantResources + self, plant_list: list[Resource], target_plant: Resource ) -> float: """Calculates the mass to be consumed from a plant resource by the herbivore. @@ -721,7 +719,7 @@ def calculate_consumed_mass_herbivory( def delta_mass_herbivory( self, - plant_list: list[PlantResources], + plant_list: list[Resource], excrement_pools: list[ExcrementPool], ) -> float: """This method handles mass assimilation from herbivory. @@ -754,7 +752,7 @@ def delta_mass_herbivory( def forage_cohort( self, - plant_list: list[PlantResources], + plant_list: list[Resource], animal_list: list[AnimalCohort], excrement_pools: list[ExcrementPool], carcass_pools: dict[int, list[CarcassPool]], @@ -1002,8 +1000,8 @@ def get_prey( return prey_list def get_plant_resources( - self, plant_resources: dict[int, list[PlantResources]] - ) -> list[PlantResources]: + self, plant_resources: dict[int, list[Resource]] + ) -> list[Resource]: """Returns a list of plant resources in this territory. This method checks which grid cells are within this territory @@ -1013,7 +1011,7 @@ def get_plant_resources( plant_resources: A dictionary of plants where keys are grid cell IDs. Returns: - A list of PlantResources objects in this territory. + A list of Resource objects in this territory. """ plant_resources_in_territory: list = [] diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 0986106f6..bd1f99154 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -41,6 +41,7 @@ get_functional_group_by_name, ) from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.protocols import Resource from virtual_ecosystem.models.animal.scaling_functions import damuths_law @@ -92,7 +93,7 @@ def __init__( """List of functional groups in the model.""" self.model_constants = model_constants """Animal constants.""" - self.plant_resources: dict[int, list[PlantResources]] = { + self.plant_resources: dict[int, list[Resource]] = { cell_id: [ PlantResources( data=self.data, cell_id=cell_id, constants=self.model_constants diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 4c3d6ecd5..bf745f18f 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -7,9 +7,9 @@ from collections.abc import Sequence from virtual_ecosystem.core.data import Data -from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import ExcrementPool +from virtual_ecosystem.models.animal.protocols import Consumer class PlantResources: @@ -59,7 +59,7 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: def get_eaten( self, consumed_mass: float, - herbivore: AnimalCohort, + herbivore: Consumer, excrement_pools: Sequence[ExcrementPool], ) -> float: """This function handles herbivory on PlantResources.""" diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py new file mode 100644 index 000000000..29f6abf3a --- /dev/null +++ b/virtual_ecosystem/models/animal/protocols.py @@ -0,0 +1,50 @@ +"""The `models.animal.protocols` module contains a class provides eatability definition +used by AnimalCohorts, PlantResources, and Carcasses in the +:mod:`~virtual_ecosystem.models.animal` module. +""" # noqa: D205 + +from typing import Protocol + +from virtual_ecosystem.models.animal.decay import ExcrementPool +from virtual_ecosystem.models.animal.functional_group import FunctionalGroup + + +class Consumer(Protocol): + """This is the protocol for defining consumers (currently just AnimalCohort).""" + + functional_group: FunctionalGroup + individuals: int + + +class Pool(Protocol): + """This is a protocol for defining dummy abiotic pools containing energy.""" + + mass_current: float + + +class DecayPool(Protocol): + """Defines biotic pools containing both accessible and inaccessible nutrients.""" + + scavengeable_carbon: float + + decomposed_carbon: float + + scavengeable_nitrogen: float + + decomposed_nitrogen: float + + scavengeable_phosphorus: float + + decomposed_phosphorus: float + + +class Resource(Protocol): + """This is the protocol for defining what classes work as trophic resources.""" + + mass_current: float + + def get_eaten( + self, consumed_mass: float, consumer: Consumer, pool: list[ExcrementPool] + ) -> float: + """The get_eaten method defines a resource.""" + ... From 5f9e8e2475f4d098c38cede2f2b860da9cf4136a Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 19 Sep 2024 16:43:25 +0100 Subject: [PATCH 41/62] Fixed existing animalcohort tests. --- tests/models/animals/conftest.py | 74 +++++------- tests/models/animals/test_animal_cohorts.py | 72 +++++++----- tests/models/animals/test_animal_model.py | 111 +++++++----------- .../models/animal/animal_model.py | 19 +-- 4 files changed, 127 insertions(+), 149 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index c0125210f..df996c64d 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -81,7 +81,7 @@ def animal_data_for_model_instance(fixture_core_components): @pytest.fixture -def animal_data_for_community_instance(fixture_core_components): +def animal_data_for_cohorts_instance(fixture_core_components): """Fixture returning a combination of plant and air temperature data.""" from virtual_ecosystem.core.data import Data @@ -170,26 +170,6 @@ def animal_model_instance( ) -@pytest.fixture -def animal_community_instance( - functional_group_list_instance, - animal_model_instance, - animal_data_for_community_instance, - constants_instance, -): - """Fixture for an animal community used in tests.""" - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity - - return AnimalCommunity( - functional_groups=functional_group_list_instance, - data=animal_data_for_community_instance, - community_key=4, - neighbouring_keys=[1, 3, 5, 7], - get_community_by_key=animal_model_instance.get_community_by_key, - constants=constants_instance, - ) - - @pytest.fixture def herbivore_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" @@ -205,7 +185,9 @@ def herbivore_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def herbivore_cohort_instance( - herbivore_functional_group_instance, animal_territory_instance, constants_instance + herbivore_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 @@ -215,7 +197,8 @@ def herbivore_cohort_instance( 10000.0, 1, 10, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -235,7 +218,9 @@ def caterpillar_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def caterpillar_cohort_instance( - caterpillar_functional_group_instance, animal_territory_instance, constants_instance + caterpillar_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 @@ -245,7 +230,8 @@ def caterpillar_cohort_instance( 1.0, 1, 100, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -265,7 +251,9 @@ def butterfly_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def butterfly_cohort_instance( - butterfly_functional_group_instance, animal_territory_instance, constants_instance + butterfly_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 @@ -275,7 +263,8 @@ def butterfly_cohort_instance( 1.0, 1, 100, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -313,7 +302,9 @@ def plant_list_instance(plant_data_instance, constants_instance): @pytest.fixture def animal_list_instance( - herbivore_functional_group_instance, animal_territory_instance, constants_instance + herbivore_functional_group_instance, + animal_data_for_cohorts_instance, + constants_instance, ): """Fixture providing a list of animal cohorts.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort @@ -324,7 +315,8 @@ def animal_list_instance( 10000.0, 1, 10, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) for idx in range(3) @@ -332,26 +324,16 @@ def animal_list_instance( @pytest.fixture -def get_community_by_key(animal_community_instance): - """Fixture for get_community_by_key.""" - - def _get_community_by_key(key): - return animal_community_instance - - return _get_community_by_key - - -@pytest.fixture -def animal_territory_instance(): - """Fixture to create an AnimalTerritory instance.""" - from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory +def carcass_pool_instance(): + """Fixture for an carcass pool used in tests.""" + from virtual_ecosystem.models.animal.decay import CarcassPool - return AnimalTerritory(centroid=1, grid_cell_keys=[1, 2, 3]) + return CarcassPool(0.0, 0.0) @pytest.fixture -def carcass_pool_instance(): - """Fixture for an carcass pool used in tests.""" +def carcass_pools_instance(): + """Fixture for carcass pools used in tests.""" from virtual_ecosystem.models.animal.decay import CarcassPool - return CarcassPool(0.0, 0.0) + return {1: [CarcassPool(scavengeable_energy=500.0, decomposed_energy=0.0)]} diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index e096f9883..d058066e4 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -19,17 +19,20 @@ def predator_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def predator_cohort_instance( - predator_functional_group_instance, animal_territory_instance, constants_instance + predator_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( - predator_functional_group_instance, - 10000.0, - 1, - 10, - animal_territory_instance, + predator_functional_group_instance, # functional group + 10000.0, # mass + 1, # age + 10, # individuals + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -49,7 +52,9 @@ def ectotherm_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def ectotherm_cohort_instance( - ectotherm_functional_group_instance, animal_territory_instance, constants_instance + ectotherm_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 @@ -59,14 +64,17 @@ def ectotherm_cohort_instance( 100.0, 1, 10, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @pytest.fixture def prey_cohort_instance( - herbivore_functional_group_instance, animal_territory_instance, constants_instance + herbivore_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 @@ -76,7 +84,8 @@ def prey_cohort_instance( 100.0, 1, 10, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -105,7 +114,7 @@ def test_invalid_animal_cohort_initialization( age, individuals, error_type, - animal_territory_instance, + animal_data_for_cohorts_instance, constants_instance, ): """Test for invalid inputs during AnimalCohort initialization.""" @@ -117,7 +126,8 @@ def test_invalid_animal_cohort_initialization( mass, age, individuals, - animal_territory_instance, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -612,7 +622,7 @@ def test_get_eaten( carcass_pool_1 = mocker.Mock() carcass_pool_2 = mocker.Mock() mock_find_intersecting_carcass_pools = mocker.patch.object( - herbivore_cohort_instance.territory, + herbivore_cohort_instance, "find_intersecting_carcass_pools", return_value=[carcass_pool_1, carcass_pool_2], ) @@ -622,9 +632,12 @@ def test_get_eaten( herbivore_cohort_instance, "update_carcass_pool" ) + # Provide a mocked carcass_pools to pass to the get_eaten method + carcass_pools = {1: [carcass_pool_1, carcass_pool_2]} + # Execute the get_eaten method with test parameters actual_consumed_mass = herbivore_cohort_instance.get_eaten( - potential_consumed_mass, predator_cohort_instance + potential_consumed_mass, predator_cohort_instance, carcass_pools ) # Calculate expected individuals killed @@ -649,7 +662,7 @@ def test_get_eaten( # Check if find_intersecting_carcass_pools was called correctly mock_find_intersecting_carcass_pools.assert_called_once_with( - predator_cohort_instance.territory + predator_cohort_instance.territory, carcass_pools ) @pytest.mark.parametrize( @@ -763,7 +776,7 @@ def test_calculate_alpha( mass_current, expected_alpha, herbivore_functional_group_instance, - animal_territory_instance, + animal_data_for_cohorts_instance, ): """Testing for calculate alpha.""" # Assuming necessary imports and setup based on previous examples @@ -786,7 +799,8 @@ def test_calculate_alpha( mass=mass_current, age=1.0, # Example age individuals=1, # Example number of individuals - territory=animal_territory_instance, + centroid_key=1, # centroid + grid=animal_data_for_cohorts_instance.grid, # grid constants=constants, ) @@ -814,7 +828,7 @@ def test_calculate_potential_consumed_biomass( mass_current, phi_herb_t, expected_biomass, - animal_territory_instance, + animal_data_for_cohorts_instance, ): """Testing for calculate_potential_consumed_biomass.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort @@ -847,7 +861,8 @@ def test_calculate_potential_consumed_biomass( mass=100.0, # Arbitrary value since mass is not directly used in this test age=1.0, # Arbitrary value individuals=1, # Arbitrary value - territory=animal_territory_instance, + centroid_key=1, # Use centroid_key instead of centroid + grid=animal_data_for_cohorts_instance.grid, # grid constants=mocker.MagicMock(), ) @@ -1194,6 +1209,7 @@ def test_delta_mass_predation( predator_cohort_instance, animal_list_instance, excrement_pool_instance, + carcass_pool_instance, # Add carcass_pool_instance consumed_mass, expected_total_consumed_mass, ): @@ -1219,16 +1235,13 @@ def test_delta_mass_predation( # Mock predator_cohort_instance.defecate to verify its call mock_defecate = mocker.patch.object(predator_cohort_instance, "defecate") + # Add carcass_pool_instance to the test call total_consumed_mass = predator_cohort_instance.delta_mass_predation( - animal_list_instance, excrement_pool_instance + animal_list_instance, excrement_pool_instance, carcass_pool_instance ) - # Check if the total consumed mass matches the expected value - assert ( - total_consumed_mass == expected_total_consumed_mass - ), "Total consumed mass should match expected value." - - # Ensure defecate was called with the correct total consumed mass + # Assertions + assert total_consumed_mass == expected_total_consumed_mass mock_defecate.assert_called_once_with( excrement_pool_instance, total_consumed_mass ) @@ -1282,6 +1295,7 @@ def test_forage_cohort( plant_list_instance, animal_list_instance, excrement_pool_instance, + carcass_pools_instance, ): """Test foraging behavior for different diet types.""" @@ -1297,7 +1311,7 @@ def test_forage_cohort( # Test herbivore diet herbivore_cohort_instance.forage_cohort( - plant_list_instance, [], excrement_pool_instance + plant_list_instance, [], excrement_pool_instance, carcass_pools_instance ) mock_delta_mass_herbivory.assert_called_once_with( plant_list_instance, excrement_pool_instance @@ -1306,10 +1320,10 @@ def test_forage_cohort( # Test carnivore diet predator_cohort_instance.forage_cohort( - [], animal_list_instance, excrement_pool_instance + [], animal_list_instance, excrement_pool_instance, carcass_pools_instance ) mock_delta_mass_predation.assert_called_once_with( - animal_list_instance, excrement_pool_instance + animal_list_instance, excrement_pool_instance, carcass_pools_instance ) mock_eat_predator.assert_called_once_with(200) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 16f62cbdb..eddd5b345 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -224,32 +224,10 @@ class FilteredCaplog: print(f"Level: {record.levelname}, Message: {record.message}") -def test_get_community_by_key(animal_model_instance): - """Test the `get_community_by_key` method.""" - - from virtual_ecosystem.models.animal.animal_model import AnimalCommunity - - # If you know that your model_instance should have a community with key 0 - community_0 = animal_model_instance.get_community_by_key(0) - - # Ensure it returns the right type and the community key matches - assert isinstance( - community_0, AnimalCommunity - ), "Expected instance of AnimalCommunity" - assert community_0.community_key == 0, "Expected the community with key 0" - - # Perhaps you have more keys you expect, you can add similar checks: - community_1 = animal_model_instance.get_community_by_key(1) - assert isinstance(community_1, AnimalCommunity) - assert community_1.community_key == 1, "Expected the community with key 1" - - # Test for an invalid key, expecting an error - with pytest.raises(KeyError): - animal_model_instance.get_community_by_key(999) - - def test_update_method_sequence(mocker, prepared_animal_model_instance): """Test update to ensure it runs the community methods in order.""" + + # List of methods that should be called in the update sequence method_names = [ "forage_community", "migrate_community", @@ -261,26 +239,25 @@ def test_update_method_sequence(mocker, prepared_animal_model_instance): "increase_age_community", ] - # Setup mock methods using spy - for community in prepared_animal_model_instance.communities.values(): - for method_name in method_names: - mocker.spy(community, method_name) + # Setup mock methods using spy on the prepared_animal_model_instance itself + for method_name in method_names: + mocker.spy(prepared_animal_model_instance, method_name) + # Call the update method prepared_animal_model_instance.update(time_index=0) - # Now, let's verify the order of the calls for each community - for community in prepared_animal_model_instance.communities.values(): - called_methods = [] - for method_name in method_names: - method = getattr(community, method_name) - # If the method was called, add its name to the list - if method.spy_return is not None or method.call_count > 0: - called_methods.append(method_name) + # Verify the order of the method calls + called_methods = [] + for method_name in method_names: + method = getattr(prepared_animal_model_instance, method_name) + # If the method was called, add its name to the list + if method.spy_return is not None or method.call_count > 0: + called_methods.append(method_name) - # Verify the called_methods list matches the expected method_names list - assert ( - called_methods == method_names - ), f"Methods called in wrong order: {called_methods} for community {community}" + # Ensure the methods were called in the expected order + assert ( + called_methods == method_names + ), f"Methods called in wrong order: {called_methods}" def test_update_method_time_index_argument( @@ -307,7 +284,7 @@ def test_calculate_litter_additions( config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') core_components = CoreComponents(config) - # Use it to initialise the model + # Use it to initialize the model model = AnimalModel( data=animal_data_for_model_instance, core_components=core_components, @@ -316,12 +293,18 @@ def test_calculate_litter_additions( # Update the waste pools decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6, 0, 0, 0, 0, 0] - for energy, community in zip(decomposed_excrement, model.communities.values()): - community.excrement_pool.decomposed_energy = energy + for energy, excrement_pools in zip( + decomposed_excrement, model.excrement_pools.values() + ): + for excrement_pool in excrement_pools: + excrement_pool.decomposed_energy = energy decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8, 0, 0, 0, 0, 0] - for energy, community in zip(decomposed_carcasses, model.communities.values()): - community.carcass_pool.decomposed_energy = energy + for energy, carcass_pools in zip( + decomposed_carcasses, model.carcass_pools.values() + ): + for carcass_pool in carcass_pools: + carcass_pool.decomposed_energy = energy # Calculate litter additions litter_additions = model.calculate_litter_additions() @@ -337,20 +320,11 @@ def test_calculate_litter_additions( ) # Check that the function has reset the pools correctly - assert np.allclose( - [ - community.excrement_pool.decomposed_energy - for community in model.communities.values() - ], - 0.0, - ) - assert np.allclose( - [ - community.carcass_pool.decomposed_energy - for community in model.communities.values() - ], - 0.0, - ) + for excrement_pools in model.excrement_pools.values(): + assert np.allclose([pool.decomposed_energy for pool in excrement_pools], 0.0) + + for carcass_pools in model.carcass_pools.values(): + assert np.allclose([pool.decomposed_energy for pool in carcass_pools], 0.0) def test_setup_initializes_total_animal_respiration( @@ -435,16 +409,21 @@ def test_update_population_densities(prepared_animal_model_instance): # Set up expected densities expected_densities = {} - # For simplicity in this example, assume we manually calculate expected densities - # based on your cohort setup logic. In practice, you would calculate these - # based on your specific test setup conditions. + # Manually calculate expected densities based on the cohorts in the community for community_id, community in prepared_animal_model_instance.communities.items(): expected_densities[community_id] = {} - for fg_name, cohorts in community.animal_cohorts.items(): - total_individuals = sum(cohort.individuals for cohort in cohorts) + + # Iterate through the list of cohorts in each community + for cohort in community: + fg_name = cohort.functional_group.name + total_individuals = cohort.individuals community_area = prepared_animal_model_instance.data.grid.cell_area density = total_individuals / community_area - expected_densities[community_id][fg_name] = density + + # Accumulate density for each functional group + if fg_name not in expected_densities[community_id]: + expected_densities[community_id][fg_name] = 0.0 + expected_densities[community_id][fg_name] += density # Run the method under test prepared_animal_model_instance.update_population_densities() @@ -460,7 +439,7 @@ def test_update_population_densities(prepared_animal_model_instance): ).item() expected_density = expected_densities[community_id][fg_name] assert calculated_density == pytest.approx(expected_density), ( - f"Mismatch in density for community {community_id} and FG{fg_name}. " + f"Mismatch in density for community {community_id} and FG {fg_name}. " f"Expected: {expected_density}, Found: {calculated_density}" ) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index bd1f99154..485ef50fa 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -509,8 +509,6 @@ def remove_dead_cohort(self, cohort: AnimalCohort) -> None: This method removes the cohort from every community listed in its territory's grid cell keys, and then removes it from the model's main cohort dictionary. - TODO: this might also need to remove territory objects - Args: cohort: The AnimalCohort to be removed. @@ -518,10 +516,10 @@ def remove_dead_cohort(self, cohort: AnimalCohort) -> None: KeyError: If the cohort ID does not exist in the model's cohorts. """ # Check if the cohort exists in self.cohorts - if cohort.id in self.cohorts.values(): + if cohort.id in self.cohorts: # Iterate over all grid cell keys in the cohort's territory for cell_id in cohort.territory: - if cell_id in self.communities: + if cell_id in self.communities and cohort in self.communities[cell_id]: self.communities[cell_id].remove(cohort) # Remove the cohort from the model's cohorts dictionary @@ -531,10 +529,15 @@ def remove_dead_cohort(self, cohort: AnimalCohort) -> None: def remove_dead_cohort_community(self) -> None: """This handles remove_dead_cohort for all cohorts in a community.""" - for cohort in self.cohorts.values(): - if cohort.individuals <= 0: - cohort.is_alive = False - self.remove_dead_cohort(cohort) + # Collect cohorts to remove (to avoid modifying the dictionary during iteration) + cohorts_to_remove = [ + cohort for cohort in self.cohorts.values() if cohort.individuals <= 0 + ] + + # Remove each cohort + for cohort in cohorts_to_remove: + cohort.is_alive = False + self.remove_dead_cohort(cohort) def birth(self, parent_cohort: AnimalCohort) -> None: """Produce a new AnimalCohort through reproduction. From 351990f98aed8bf13ae7d1c87a41de78c741d78f Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 27 Sep 2024 12:05:02 +0100 Subject: [PATCH 42/62] Fixed pre-existing animal model tests. --- .../models/animal/animal_model.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 485ef50fa..8a189a856 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -279,7 +279,7 @@ def update(self, time_index: int, **kwargs: Any) -> None: self.birth_community() self.metamorphose_community() self.metabolize_community( - float(self.data["air_temperature"][0][self.communities.keys()].values), + self.data["air_temperature"], self.update_interval_timedelta, ) self.inflict_non_predation_mortality_community(self.update_interval_timedelta) @@ -644,7 +644,9 @@ def forage_community(self) -> None: # temporary solution self.remove_dead_cohort_community() - def metabolize_community(self, temperature: float, dt: timedelta64) -> None: + def metabolize_community( + self, air_temperature_data: DataArray, dt: timedelta64 + ) -> None: """This handles metabolize for all cohorts in a community. This method generates a total amount of metabolic waste per cohort and passes @@ -656,22 +658,35 @@ def metabolize_community(self, temperature: float, dt: timedelta64) -> None: Excretion wastes are handled cohort by cohort because they will need to be spatially explicit with multi-grid occupancy. - TODO: Rework with stoichiometry - Args: - temperature: Current air temperature (K). + air_temperature_data: The full air temperature data (as a DataArray) for + all communities. dt: Number of days over which the metabolic costs should be calculated. """ for cell_id, community in self.communities.items(): + # Check for empty community and skip processing if empty + if not community: + continue + total_carbonaceous_waste = 0.0 + # Extract the temperature for this specific community (cell_id) + temperature_for_cell = float( + air_temperature_data.loc[{"cell_id": cell_id}].values + ) + for cohort in community: - metabolic_waste_mass = cohort.metabolize(temperature, dt) + # Calculate metabolic waste based on cohort properties + metabolic_waste_mass = cohort.metabolize(temperature_for_cell, dt) + + # Carbonaceous waste from respiration total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) + + # Excretion of waste into the excrement pool cohort.excrete(metabolic_waste_mass, self.excrement_pools[cell_id]) - # Update the total_animal_respiration for this cell_id. + # Update the total_animal_respiration for the specific cell_id self.data["total_animal_respiration"].loc[{"cell_id": cell_id}] += ( total_carbonaceous_waste ) @@ -697,7 +712,7 @@ def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: """ number_of_days = float(dt / timedelta64(1, "D")) - for cohort in self.cohorts.values(): + for cohort in list(self.cohorts.values()): cohort.inflict_non_predation_mortality( number_of_days, cohort.get_carcass_pools(self.carcass_pools) ) @@ -755,7 +770,8 @@ def metamorphose(self, larval_cohort: AnimalCohort) -> None: def metamorphose_community(self) -> None: """Handle metamorphosis for all applicable cohorts in the community.""" - for cohort in self.cohorts.values(): + # Iterate over a static list of cohort values + for cohort in list(self.cohorts.values()): if ( cohort.functional_group.development_type == DevelopmentType.INDIRECT and (cohort.mass_current >= cohort.functional_group.adult_mass) From feed6ed380503b8b44efb4a5ff71f7a5d4660cad Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 27 Sep 2024 16:14:07 +0100 Subject: [PATCH 43/62] New tests for migrate and migrate_community. --- tests/models/animals/conftest.py | 4 +- tests/models/animals/test_animal_model.py | 1079 +++++++++++------ .../models/animal/animal_model.py | 39 +- 3 files changed, 692 insertions(+), 430 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index df996c64d..35a23f324 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -153,7 +153,7 @@ def functional_group_list_instance(shared_datadir, constants_instance): @pytest.fixture def animal_model_instance( - animal_data_for_community_instance, + animal_data_for_model_instance, fixture_core_components, functional_group_list_instance, constants_instance, @@ -163,7 +163,7 @@ def animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel return AnimalModel( - data=animal_data_for_community_instance, + data=animal_data_for_model_instance, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index eddd5b345..d18dd11c3 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -29,442 +29,737 @@ def prepared_animal_model_instance( return model -def test_animal_model_initialization( - animal_data_for_model_instance, - fixture_core_components, - functional_group_list_instance, - constants_instance, -): - """Test `AnimalModel` initialization.""" - from virtual_ecosystem.core.base_model import BaseModel - from virtual_ecosystem.models.animal.animal_model import AnimalModel +class TestAnimalModel: + """Test the AnimalModel class.""" + + def test_animal_model_initialization( + self, + animal_data_for_model_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test `AnimalModel` initialization.""" + from virtual_ecosystem.core.base_model import BaseModel + from virtual_ecosystem.models.animal.animal_model import AnimalModel - # Initialize model - model = AnimalModel( - data=animal_data_for_model_instance, - core_components=fixture_core_components, - functional_groups=functional_group_list_instance, - model_constants=constants_instance, - ) + # Initialize model + model = AnimalModel( + data=animal_data_for_model_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) - # In cases where it passes then checks that the object has the right properties - assert isinstance(model, BaseModel) - assert model.model_name == "animal" - assert str(model) == "A animal model instance" - assert repr(model) == "AnimalModel(update_interval=1209600 seconds)" - assert isinstance(model.communities, dict) - - -@pytest.mark.parametrize( - "config_string,raises,expected_log_entries", - [ - pytest.param( - """[core.timing] - start_date = "2020-01-01" - update_interval = "7 days" - [[animal.functional_groups]] - name = "carnivorous_bird" - taxa = "bird" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.1 - adult_mass = 1.0 - [[animal.functional_groups]] - name = "herbivorous_bird" - taxa = "bird" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.05 - adult_mass = 0.5 - [[animal.functional_groups]] - name = "carnivorous_mammal" - taxa = "mammal" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 4.0 - adult_mass = 40.0 - [[animal.functional_groups]] - name = "herbivorous_mammal" - taxa = "mammal" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 1.0 - adult_mass = 10.0 - [[animal.functional_groups]] - name = "carnivorous_insect" - taxa = "insect" - diet = "carnivore" - metabolic_type = "ectothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.001 - adult_mass = 0.01 - [[animal.functional_groups]] - name = "herbivorous_insect" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "butterfly" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "indirect" - development_status = "adult" - offspring_functional_group = "caterpillar" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "caterpillar" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "nonreproductive" - development_type = "indirect" - development_status = "larval" - offspring_functional_group = "butterfly" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - """, - does_not_raise(), - ( - (INFO, "Initialised animal.AnimalConsts from config"), + # In cases where it passes then checks that the object has the right properties + assert isinstance(model, BaseModel) + assert model.model_name == "animal" + assert str(model) == "A animal model instance" + assert repr(model) == "AnimalModel(update_interval=1209600 seconds)" + assert isinstance(model.communities, dict) + + @pytest.mark.parametrize( + "config_string,raises,expected_log_entries", + [ + pytest.param( + """[core.timing] + start_date = "2020-01-01" + update_interval = "7 days" + [[animal.functional_groups]] + name = "carnivorous_bird" + taxa = "bird" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.1 + adult_mass = 1.0 + [[animal.functional_groups]] + name = "herbivorous_bird" + taxa = "bird" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.05 + adult_mass = 0.5 + [[animal.functional_groups]] + name = "carnivorous_mammal" + taxa = "mammal" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 4.0 + adult_mass = 40.0 + [[animal.functional_groups]] + name = "herbivorous_mammal" + taxa = "mammal" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 1.0 + adult_mass = 10.0 + [[animal.functional_groups]] + name = "carnivorous_insect" + taxa = "insect" + diet = "carnivore" + metabolic_type = "ectothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.001 + adult_mass = 0.01 + [[animal.functional_groups]] + name = "herbivorous_insect" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "butterfly" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "indirect" + development_status = "adult" + offspring_functional_group = "caterpillar" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "caterpillar" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "nonreproductive" + development_type = "indirect" + development_status = "larval" + offspring_functional_group = "butterfly" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + """, + does_not_raise(), ( - INFO, - "Information required to initialise the animal model successfully " - "extracted.", + (INFO, "Initialised animal.AnimalConsts from config"), + ( + INFO, + "Information required to initialise the animal model " + "successfully extracted.", + ), + (INFO, "Adding data array for 'total_animal_respiration'"), + (INFO, "Adding data array for 'population_densities'"), + (INFO, "Adding data array for 'decomposed_excrement'"), + (INFO, "Adding data array for 'decomposed_carcasses'"), ), - (INFO, "Adding data array for 'total_animal_respiration'"), - (INFO, "Adding data array for 'population_densities'"), - (INFO, "Adding data array for 'decomposed_excrement'"), - (INFO, "Adding data array for 'decomposed_carcasses'"), + id="success", ), - id="success", - ), - ], -) -def test_generate_animal_model( - caplog, - animal_data_for_model_instance, - config_string, - raises, - expected_log_entries, -): - """Test that the function to initialise the animal model behaves as expected.""" - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.models.animal.animal_model import AnimalModel + ], + ) + def test_generate_animal_model( + self, + caplog, + animal_data_for_model_instance, + config_string, + raises, + expected_log_entries, + ): + """Test that the function to initialise the animal model behaves as expected.""" + from virtual_ecosystem.core.config import Config + from virtual_ecosystem.core.core_components import CoreComponents + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Build the config object and core components + config = Config(cfg_strings=config_string) + core_components = CoreComponents(config) + caplog.clear() + + # Check whether model is initialised (or not) as expected + with raises: + model = AnimalModel.from_config( + data=animal_data_for_model_instance, + core_components=core_components, + config=config, + ) + + # Run the update step (once this does something should check output) + model.update(time_index=0) + + # Print the captured log messages to debug + for record in caplog.records: + print(f"Log Level: {record.levelno}, Message: {record.message}") + + # Filter out stochastic log entries + filtered_records = [ + record + for record in caplog.records + if "No individuals in cohort to forage." not in record.message + ] + + # Create a new caplog object to pass to log_check + class FilteredCaplog: + records = filtered_records + + filtered_caplog = FilteredCaplog() + + # Final check that expected logging entries are produced + log_check(filtered_caplog, expected_log_entries) + + for record in caplog.records: + print(f"Level: {record.levelname}, Message: {record.message}") + + def test_update_method_sequence(self, mocker, prepared_animal_model_instance): + """Test update to ensure it runs the community methods in order.""" + + # List of methods that should be called in the update sequence + method_names = [ + "forage_community", + "migrate_community", + "birth_community", + "metamorphose_community", + "metabolize_community", + "inflict_non_predation_mortality_community", + "remove_dead_cohort_community", + "increase_age_community", + ] + + # Setup mock methods using spy on the prepared_animal_model_instance itself + for method_name in method_names: + mocker.spy(prepared_animal_model_instance, method_name) + + # Call the update method + prepared_animal_model_instance.update(time_index=0) + + # Verify the order of the method calls + called_methods = [] + for method_name in method_names: + method = getattr(prepared_animal_model_instance, method_name) + # If the method was called, add its name to the list + if method.spy_return is not None or method.call_count > 0: + called_methods.append(method_name) + + # Ensure the methods were called in the expected order + assert ( + called_methods == method_names + ), f"Methods called in wrong order: {called_methods}" + + def test_update_method_time_index_argument( + self, + prepared_animal_model_instance, + ): + """Test update to ensure the time index argument does not create an error.""" - # Build the config object and core components - config = Config(cfg_strings=config_string) - core_components = CoreComponents(config) - caplog.clear() + time_index = 5 + prepared_animal_model_instance.update(time_index=time_index) + + assert True + + def test_calculate_litter_additions( + self, functional_group_list_instance, animal_data_for_model_instance + ): + """Test that litter additions from animal model are calculated correctly.""" - # Check whether model is initialised (or not) as expected - with raises: - model = AnimalModel.from_config( + from virtual_ecosystem.core.config import Config + from virtual_ecosystem.core.core_components import CoreComponents + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Build the config object and core components + config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') + core_components = CoreComponents(config) + + # Use it to initialize the model + model = AnimalModel( data=animal_data_for_model_instance, core_components=core_components, - config=config, + functional_groups=functional_group_list_instance, ) - # Run the update step (once this does something should check output) - model.update(time_index=0) + # Update the waste pools + decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6, 0, 0, 0, 0, 0] + for energy, excrement_pools in zip( + decomposed_excrement, model.excrement_pools.values() + ): + for excrement_pool in excrement_pools: + excrement_pool.decomposed_energy = energy + + decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8, 0, 0, 0, 0, 0] + for energy, carcass_pools in zip( + decomposed_carcasses, model.carcass_pools.values() + ): + for carcass_pool in carcass_pools: + carcass_pool.decomposed_energy = energy + + # Calculate litter additions + litter_additions = model.calculate_litter_additions() + + # Check that litter addition pools are as expected + assert np.allclose( + litter_additions["decomposed_excrement"], + [5e-08, 8e-07, 8.42857e-07, 3.28571e-05, 0, 0, 0, 0, 0], + ) + assert np.allclose( + litter_additions["decomposed_carcasses"], + [1.0714e-4, 4.8571e-4, 1.15714e-3, 2.42857e-3, 0, 0, 0, 0, 0], + ) - # Filter out stochastic log entries - filtered_records = [ - record - for record in caplog.records - if "No individuals in cohort to forage." not in record.message - ] + # Check that the function has reset the pools correctly + for excrement_pools in model.excrement_pools.values(): + assert np.allclose( + [pool.decomposed_energy for pool in excrement_pools], 0.0 + ) - # Create a new caplog object to pass to log_check - class FilteredCaplog: - records = filtered_records + for carcass_pools in model.carcass_pools.values(): + assert np.allclose([pool.decomposed_energy for pool in carcass_pools], 0.0) - filtered_caplog = FilteredCaplog() + def test_setup_initializes_total_animal_respiration( + self, + prepared_animal_model_instance, + ): + """Test that the setup method for the total_animal_respiration variable.""" + import numpy as np + from xarray import DataArray + + # Check if 'total_animal_respiration' is in the data object + assert ( + "total_animal_respiration" in prepared_animal_model_instance.data + ), "'total_animal_respiration' should be initialized in the data object." + + # Retrieve the total_animal_respiration DataArray from the model's data object + total_animal_respiration = prepared_animal_model_instance.data[ + "total_animal_respiration" + ] + + # Check that total_animal_respiration is an instance of xarray.DataArray + assert isinstance( + total_animal_respiration, DataArray + ), "'total_animal_respiration' should be an instance of xarray.DataArray." + + # Check the initial values of total_animal_respiration are all zeros + assert np.all( + total_animal_respiration.values == 0 + ), "Initial values of 'total_animal_respiration' should be all zeros." + + # Optionally, you can also check the dimensions and coordinates + # This is useful if your setup method is supposed to initialize the data + # variable with specific dimensions or coordinates based on your model's + # structure + assert ( + "cell_id" in total_animal_respiration.dims + ), "'cell_id' should be a dimension of 'total_animal_respiration'." + + def test_population_density_initialization( + self, + prepared_animal_model_instance, + ): + """Test the initialization of the population density data variable.""" + + # Check that 'population_densities' is in the data + assert ( + "population_densities" in prepared_animal_model_instance.data.data.data_vars + ), "'population_densities' data variable not found in Data object after setup." + + # Retrieve the population densities data variable + population_densities = prepared_animal_model_instance.data[ + "population_densities" + ] + + # Check dimensions + expected_dims = ["community_id", "functional_group_id"] + assert all( + dim in population_densities.dims for dim in expected_dims + ), f"Expected dimensions {expected_dims} not found in 'population_densities'." + + # Check coordinates + # you should adjust according to actual community IDs and functional group names + expected_community_ids = list(prepared_animal_model_instance.communities.keys()) + expected_functional_group_names = [ + fg.name for fg in prepared_animal_model_instance.functional_groups + ] + assert ( + population_densities.coords["community_id"].values.tolist() + == expected_community_ids + ), "Community IDs in 'population_densities' do not match expected values." + assert ( + population_densities.coords["functional_group_id"].values.tolist() + == expected_functional_group_names + ), "Functional group names in 'population_densities' do not match" + "expected values." + + # Assuming densities have been updated, check if densities are greater than or + # equal to zero + assert np.all( + population_densities.values >= 0 + ), "Population densities should be greater than or equal to zero." + + def test_update_population_densities(self, prepared_animal_model_instance): + """Test that the update_population_densities method correctly updates.""" + + # Set up expected densities + expected_densities = {} + + # Manually calculate expected densities based on the cohorts in the community + for ( + community_id, + community, + ) in prepared_animal_model_instance.communities.items(): + expected_densities[community_id] = {} + + # Iterate through the list of cohorts in each community + for cohort in community: + fg_name = cohort.functional_group.name + total_individuals = cohort.individuals + community_area = prepared_animal_model_instance.data.grid.cell_area + density = total_individuals / community_area + + # Accumulate density for each functional group + if fg_name not in expected_densities[community_id]: + expected_densities[community_id][fg_name] = 0.0 + expected_densities[community_id][fg_name] += density + + # Run the method under test + prepared_animal_model_instance.update_population_densities() + + # Retrieve the updated population densities data variable + population_densities = prepared_animal_model_instance.data[ + "population_densities" + ] + + # Verify updated densities match expected values + for community_id in expected_densities: + for fg_name in expected_densities[community_id]: + calculated_density = population_densities.sel( + community_id=community_id, functional_group_id=fg_name + ).item() + expected_density = expected_densities[community_id][fg_name] + assert calculated_density == pytest.approx(expected_density), ( + f"Mismatch in density for community {community_id}" + " and FG {fg_name}. " + f"Expected: {expected_density}, Found: {calculated_density}" + ) + + def test_calculate_density_for_cohort(self, prepared_animal_model_instance, mocker): + """Test the calculate_density_for_cohort method.""" + + mock_cohort = mocker.MagicMock() + mock_cohort.individuals = 100 # Example number of individuals + + # Set a known community area in the model's data.grid.cell_area + prepared_animal_model_instance.data.grid.cell_area = 2000 # Example area in m2 + + # Expected density = individuals / area + expected_density = ( + mock_cohort.individuals / prepared_animal_model_instance.data.grid.cell_area + ) - # Final check that expected logging entries are produced - log_check(filtered_caplog, expected_log_entries) + # Calculate density using the method under test + calculated_density = ( + prepared_animal_model_instance.calculate_density_for_cohort(mock_cohort) + ) - for record in caplog.records: - print(f"Level: {record.levelname}, Message: {record.message}") + # Assert the calculated density matches the expected density + assert calculated_density == pytest.approx(expected_density), ( + f"Calculated density ({calculated_density}) " + f"did not match expected density ({expected_density})." + ) + def test_initialize_communities( + self, + animal_data_for_model_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test that `_initialize_communities` generates cohorts.""" -def test_update_method_sequence(mocker, prepared_animal_model_instance): - """Test update to ensure it runs the community methods in order.""" + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.animal_model import AnimalModel - # List of methods that should be called in the update sequence - method_names = [ - "forage_community", - "migrate_community", - "birth_community", - "metamorphose_community", - "metabolize_community", - "inflict_non_predation_mortality_community", - "remove_dead_cohort_community", - "increase_age_community", - ] + # Initialize the model + model = AnimalModel( + data=animal_data_for_model_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) - # Setup mock methods using spy on the prepared_animal_model_instance itself - for method_name in method_names: - mocker.spy(prepared_animal_model_instance, method_name) + # Call the method to initialize communities + model._initialize_communities(functional_group_list_instance) - # Call the update method - prepared_animal_model_instance.update(time_index=0) + # Assert that cohorts have been generated in each community + for cell_id in animal_data_for_model_instance.grid.cell_id: + assert len(model.communities[cell_id]) > 0 + for cohort in model.communities[cell_id]: + assert isinstance(cohort, AnimalCohort) - # Verify the order of the method calls - called_methods = [] - for method_name in method_names: - method = getattr(prepared_animal_model_instance, method_name) - # If the method was called, add its name to the list - if method.spy_return is not None or method.call_count > 0: - called_methods.append(method_name) + # Assert that cohorts are stored in the model's cohort dictionary + assert len(model.cohorts) > 0 - # Ensure the methods were called in the expected order - assert ( - called_methods == method_names - ), f"Methods called in wrong order: {called_methods}" + def test_abandon_communities( + self, + animal_model_instance, + herbivore_cohort_instance, + ): + """Test that `abandon_communities` removes a cohort from all communities.""" + + # Assign the cohort to multiple territories (two cells) + cohort = herbivore_cohort_instance + cohort.territory = [ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ] + + # Add the cohort to multiple communities in the animal model + animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ].append(cohort) + animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ].append(cohort) + + # Assert that the cohort is present in the communities before abandonment + assert ( + cohort + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + cohort + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) + # Call the abandon_communities method to remove the cohort + animal_model_instance.abandon_communities(cohort) -def test_update_method_time_index_argument( - prepared_animal_model_instance, -): - """Test update to ensure the time index argument does not create an error.""" + # Assert that the cohort is removed from both communities + assert ( + cohort + not in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + cohort + not in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) - time_index = 5 - prepared_animal_model_instance.update(time_index=time_index) + def test_update_community_occupancy( + self, animal_model_instance, herbivore_cohort_instance, mocker + ): + """Test update_community_occupancy.""" + + # Mock the get_territory_cells method to return specific territory cells + mocker.patch.object( + herbivore_cohort_instance, + "get_territory_cells", + return_value=[ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ], + ) - assert True + # Spy on the update_territory method to check if it's called + spy_update_territory = mocker.spy(herbivore_cohort_instance, "update_territory") + # Choose a centroid key (e.g., the first grid cell) + centroid_key = animal_model_instance.data.grid.cell_id[0] -def test_calculate_litter_additions( - functional_group_list_instance, animal_data_for_model_instance -): - """Test that litter additions from animal model are calculated correctly.""" + # Call the method to update community occupancy + animal_model_instance.update_community_occupancy( + herbivore_cohort_instance, centroid_key + ) - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.models.animal.animal_model import AnimalModel + # Check if the cohort's territory was updated correctly + spy_update_territory.assert_called_once_with( + [ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ] + ) - # Build the config object and core components - config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') - core_components = CoreComponents(config) + # Check if the cohort has been added to the appropriate communities + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) - # Use it to initialize the model - model = AnimalModel( - data=animal_data_for_model_instance, - core_components=core_components, - functional_groups=functional_group_list_instance, - ) + def test_migrate(self, animal_model_instance, herbivore_cohort_instance, mocker): + """Test that `migrate` correctly moves an AnimalCohort between grid cells.""" - # Update the waste pools - decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6, 0, 0, 0, 0, 0] - for energy, excrement_pools in zip( - decomposed_excrement, model.excrement_pools.values() - ): - for excrement_pool in excrement_pools: - excrement_pool.decomposed_energy = energy + # Mock the abandonment and community occupancy update methods + mock_abandon_communities = mocker.patch.object( + animal_model_instance, "abandon_communities" + ) + mock_update_community_occupancy = mocker.patch.object( + animal_model_instance, "update_community_occupancy" + ) - decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8, 0, 0, 0, 0, 0] - for energy, carcass_pools in zip( - decomposed_carcasses, model.carcass_pools.values() - ): - for carcass_pool in carcass_pools: - carcass_pool.decomposed_energy = energy + # Assign the cohort to a specific starting grid cell + initial_cell = animal_model_instance.data.grid.cell_id[0] + destination_cell = animal_model_instance.data.grid.cell_id[1] - # Calculate litter additions - litter_additions = model.calculate_litter_additions() + herbivore_cohort_instance.centroid_key = initial_cell + animal_model_instance.communities[initial_cell].append( + herbivore_cohort_instance + ) - # Check that litter addition pools are as expected - assert np.allclose( - litter_additions["decomposed_excrement"], - [5e-08, 8e-07, 8.42857e-07, 3.28571e-05, 0, 0, 0, 0, 0], - ) - assert np.allclose( - litter_additions["decomposed_carcasses"], - [1.0714e-4, 4.8571e-4, 1.15714e-3, 2.42857e-3, 0, 0, 0, 0, 0], - ) + # Check that the cohort is in the initial community before migration + assert ( + herbivore_cohort_instance in animal_model_instance.communities[initial_cell] + ) - # Check that the function has reset the pools correctly - for excrement_pools in model.excrement_pools.values(): - assert np.allclose([pool.decomposed_energy for pool in excrement_pools], 0.0) + # Call the migrate method to move the cohort to the destination cell + animal_model_instance.migrate(herbivore_cohort_instance, destination_cell) - for carcass_pools in model.carcass_pools.values(): - assert np.allclose([pool.decomposed_energy for pool in carcass_pools], 0.0) + # Assert that the cohort is no longer in the initial community + assert ( + herbivore_cohort_instance + not in animal_model_instance.communities[initial_cell] + ) + # Assert that the cohort is now in the destination community + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[destination_cell] + ) -def test_setup_initializes_total_animal_respiration( - prepared_animal_model_instance, -): - """Test that the setup method initializes the total_animal_respiration variable.""" - import numpy as np - from xarray import DataArray - - # Check if 'total_animal_respiration' is in the data object - assert ( - "total_animal_respiration" in prepared_animal_model_instance.data - ), "'total_animal_respiration' should be initialized in the data object." - - # Retrieve the total_animal_respiration DataArray from the model's data object - total_animal_respiration = prepared_animal_model_instance.data[ - "total_animal_respiration" - ] - - # Check that total_animal_respiration is an instance of xarray.DataArray - assert isinstance( - total_animal_respiration, DataArray - ), "'total_animal_respiration' should be an instance of xarray.DataArray." - - # Check the initial values of total_animal_respiration are all zeros - assert np.all( - total_animal_respiration.values == 0 - ), "Initial values of 'total_animal_respiration' should be all zeros." - - # Optionally, you can also check the dimensions and coordinates - # This is useful if your setup method is supposed to initialize the data variable - # with specific dimensions or coordinates based on your model's structure - assert ( - "cell_id" in total_animal_respiration.dims - ), "'cell_id' should be a dimension of 'total_animal_respiration'." - - -def test_population_density_initialization( - prepared_animal_model_instance, -): - """Test the initialization of the population density data variable.""" - - # Check that 'population_densities' is in the data - assert ( - "population_densities" in prepared_animal_model_instance.data.data.data_vars - ), "'population_densities' data variable not found in Data object after setup." - - # Retrieve the population densities data variable - population_densities = prepared_animal_model_instance.data["population_densities"] - - # Check dimensions - expected_dims = ["community_id", "functional_group_id"] - assert all( - dim in population_densities.dims for dim in expected_dims - ), f"Expected dimensions {expected_dims} not found in 'population_densities'." - - # Check coordinates - # you should adjust according to actual community IDs and functional group names - expected_community_ids = list(prepared_animal_model_instance.communities.keys()) - expected_functional_group_names = [ - fg.name for fg in prepared_animal_model_instance.functional_groups - ] - assert ( - population_densities.coords["community_id"].values.tolist() - == expected_community_ids - ), "Community IDs in 'population_densities' do not match expected values." - assert ( - population_densities.coords["functional_group_id"].values.tolist() - == expected_functional_group_names - ), "Functional group names in 'population_densities' do not match expected values." - - # Assuming densities have been updated, check if densities are greater than or equal - # to zero - assert np.all( - population_densities.values >= 0 - ), "Population densities should be greater than or equal to zero." - - -def test_update_population_densities(prepared_animal_model_instance): - """Test that the update_population_densities method correctly updates.""" - - # Set up expected densities - expected_densities = {} - - # Manually calculate expected densities based on the cohorts in the community - for community_id, community in prepared_animal_model_instance.communities.items(): - expected_densities[community_id] = {} - - # Iterate through the list of cohorts in each community - for cohort in community: - fg_name = cohort.functional_group.name - total_individuals = cohort.individuals - community_area = prepared_animal_model_instance.data.grid.cell_area - density = total_individuals / community_area - - # Accumulate density for each functional group - if fg_name not in expected_densities[community_id]: - expected_densities[community_id][fg_name] = 0.0 - expected_densities[community_id][fg_name] += density - - # Run the method under test - prepared_animal_model_instance.update_population_densities() - - # Retrieve the updated population densities data variable - population_densities = prepared_animal_model_instance.data["population_densities"] - - # Verify updated densities match expected values - for community_id in expected_densities: - for fg_name in expected_densities[community_id]: - calculated_density = population_densities.sel( - community_id=community_id, functional_group_id=fg_name - ).item() - expected_density = expected_densities[community_id][fg_name] - assert calculated_density == pytest.approx(expected_density), ( - f"Mismatch in density for community {community_id} and FG {fg_name}. " - f"Expected: {expected_density}, Found: {calculated_density}" - ) + # Assert that the centroid of the cohort has been updated + assert herbivore_cohort_instance.centroid_key == destination_cell + # Check that abandon_communities and update_community_occupancy were called + mock_abandon_communities.assert_called_once_with(herbivore_cohort_instance) + mock_update_community_occupancy.assert_called_once_with( + herbivore_cohort_instance, destination_cell + ) -def test_calculate_density_for_cohort(prepared_animal_model_instance, mocker): - """Test the calculate_density_for_cohort method.""" + @pytest.mark.parametrize( + "mass_ratio, age, probability_output, should_migrate", + [ + (0.5, 5.0, False, True), # Starving non-juvenile, should migrate + ( + 1.0, + 0.0, + False, + False, + ), # Well-fed juvenile, low probability, should not migrate + ( + 1.0, + 0.0, + 1.0, + True, + ), # Well-fed juvenile, high probability (1.0), should migrate + ( + 0.5, + 0.0, + 1.0, + True, + ), # Starving juvenile, high probability (1.0), should migrate + ( + 0.5, + 0.0, + 0.0, + True, + ), # Starving juvenile, low probability (0.0), should migrate + (1.0, 5.0, False, False), # Well-fed non-juvenile, should not migrate + ], + ids=[ + "starving_non_juvenile", + "well_fed_juvenile_low_prob", + "well_fed_juvenile_high_prob", + "starving_juvenile_high_prob", + "starving_juvenile_low_prob", + "well_fed_non_juvenile", + ], + ) + def test_migrate_community( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + mass_ratio, + age, + probability_output, + should_migrate, + ): + """Test migrate_community.""" + + # Empty the communities and cohorts before the test + animal_model_instance.communities = { + cell_id: [] for cell_id in animal_model_instance.communities + } + animal_model_instance.cohorts = {} + + # Set up mock cohort with dynamic mass and age values + cohort_id = herbivore_cohort_instance.id + herbivore_cohort_instance.age = age + herbivore_cohort_instance.mass_current = ( + herbivore_cohort_instance.functional_group.adult_mass * mass_ratio + ) + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + + # Mock `is_below_mass_threshold` to simulate starvation + is_starving = mass_ratio < 1.0 + mocker.patch.object( + herbivore_cohort_instance, + "is_below_mass_threshold", + return_value=is_starving, + ) - mock_cohort = mocker.MagicMock() - mock_cohort.individuals = 100 # Example number of individuals + # Mock the juvenile migration probability based on the test parameter + mocker.patch.object( + herbivore_cohort_instance, + "migrate_juvenile_probability", + return_value=probability_output, + ) - # Set a known community area in the model's data.grid.cell_area - prepared_animal_model_instance.data.grid.cell_area = 2000 # Example area in m2 + # Mock the migrate method + mock_migrate = mocker.patch.object(animal_model_instance, "migrate") - # Expected density = individuals / area - expected_density = ( - mock_cohort.individuals / prepared_animal_model_instance.data.grid.cell_area - ) + # Call the migrate_community method + animal_model_instance.migrate_community() - # Calculate density using the method under test - calculated_density = prepared_animal_model_instance.calculate_density_for_cohort( - mock_cohort - ) + # Check migration behavior + if should_migrate: + # Assert migrate was called with correct cohort + mock_migrate.assert_called_once_with(herbivore_cohort_instance, mocker.ANY) + else: + # Assert migrate was NOT called + mock_migrate.assert_not_called() - # Assert the calculated density matches the expected density - assert calculated_density == pytest.approx(expected_density), ( - f"Calculated density ({calculated_density}) " - f"did not match expected density ({expected_density})." - ) + # Assert that starvation check was applied + herbivore_cohort_instance.is_below_mass_threshold.assert_called_once() diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 8a189a856..88a152913 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -391,8 +391,9 @@ def abandon_communities(self, cohort: AnimalCohort) -> None: cohort: The cohort to be removed from the occupancy lists. """ for cell_id in cohort.territory: - if cohort.id in self.communities[cell_id]: - self.communities[cell_id].remove(cohort) + self.communities[cell_id] = [ + c for c in self.communities[cell_id] if c.id != cohort.id + ] def update_community_occupancy( self, cohort: AnimalCohort, centroid_key: int @@ -410,40 +411,6 @@ def update_community_occupancy( for cell_id in territory_cells: self.communities[cell_id].append(cohort) - def populate_community(self) -> None: - """This function creates an instance of each functional group. - - Currently, this is the simplest implementation of populating the animal model. - In each AnimalCommunity one AnimalCohort of each FunctionalGroup type is - generated. So the more functional groups that are made, the denser the animal - community will be. This function will need to be reworked dramatically later on. - - Currently, the number of individuals in a cohort is handled using Damuth's Law, - which only holds for mammals. - - TODO: Move populate_community to following Madingley instead of damuth - - """ - for cell_id, community in self.communities.items(): - for functional_group in self.functional_groups: - individuals = damuths_law( - functional_group.adult_mass, functional_group.damuths_law_terms - ) - - # create a cohort of the functional group - cohort = AnimalCohort( - functional_group, - functional_group.adult_mass, - 0.0, - individuals, - cell_id, - self.grid, - self.model_constants, - ) - # add the cohort to the flat cohort list and the specific community - community.append(cohort) - self.cohorts[cohort.id] = cohort - def migrate(self, migrant: AnimalCohort, destination_centroid: int) -> None: """Function to move an AnimalCohort between grid cells. From 62ec1c2f9ea7d5799edcc5a504cba98b8d760037 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 27 Sep 2024 18:00:24 +0100 Subject: [PATCH 44/62] Revised birth and birth testing. --- tests/models/animals/test_animal_model.py | 199 ++++++++++++++++++ .../models/animal/animal_model.py | 14 +- 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index d18dd11c3..0682a2371 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -763,3 +763,202 @@ def test_migrate_community( # Assert that starvation check was applied herbivore_cohort_instance.is_below_mass_threshold.assert_called_once() + + @pytest.mark.parametrize( + "is_cohort_in_model, expected_exception", + [ + (True, None), # Cohort exists, should be removed + (False, KeyError), # Cohort does not exist, KeyError expected + ], + ) + def test_remove_dead_cohort( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + is_cohort_in_model, + expected_exception, + ): + """Test the remove_dead_cohort method for both success and error cases.""" + + # Setup cohort ID and mock territory + cohort_id = herbivore_cohort_instance.id + herbivore_cohort_instance.territory = [ + 1, + 2, + ] # Simulate a territory covering two cells + + # If cohort should exist, add it to model's cohorts and communities + if is_cohort_in_model: + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + animal_model_instance.communities = { + 1: [herbivore_cohort_instance], + 2: [herbivore_cohort_instance], + } + + # If cohort doesn't exist, make sure it's not in the model + else: + animal_model_instance.cohorts = {} + + if expected_exception: + # Expect KeyError if cohort does not exist + with pytest.raises( + KeyError, match=f"Cohort with ID {cohort_id} does not exist." + ): + animal_model_instance.remove_dead_cohort(herbivore_cohort_instance) + else: + # Call the method to remove the cohort if it exists + animal_model_instance.remove_dead_cohort(herbivore_cohort_instance) + + # Assert that the cohort has been removed from both communities + assert herbivore_cohort_instance not in animal_model_instance.communities[1] + assert herbivore_cohort_instance not in animal_model_instance.communities[2] + + # Assert that the cohort has been removed from the model's cohorts + assert cohort_id not in animal_model_instance.cohorts + + @pytest.mark.parametrize( + "cohort_individuals, should_be_removed", + [ + (0, True), # Cohort with 0 individuals, should be removed + (10, False), # Cohort with >0 individuals, should not be removed + ], + ) + def test_remove_dead_cohort_community( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + cohort_individuals, + should_be_removed, + ): + """Test remove_dead_cohort_community for both dead and alive cohorts.""" + + # Set up cohort with individuals count + herbivore_cohort_instance.individuals = cohort_individuals + cohort_id = herbivore_cohort_instance.id + + # Add the cohort to the model's cohorts and communities + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + herbivore_cohort_instance.territory = [1, 2] # Simulate a territory + animal_model_instance.communities = { + 1: [herbivore_cohort_instance], + 2: [herbivore_cohort_instance], + } + + # Mock remove_dead_cohort to track when it is called + mock_remove_dead_cohort = mocker.patch.object( + animal_model_instance, "remove_dead_cohort" + ) + + # Call the method to remove dead cohorts from the community + animal_model_instance.remove_dead_cohort_community() + + if should_be_removed: + # If the cohort should be removed, check if remove_dead_cohort was called + mock_remove_dead_cohort.assert_called_once_with(herbivore_cohort_instance) + assert ( + herbivore_cohort_instance.is_alive is False + ) # Cohort should be marked as not alive + else: + # If cohort should not be removed, ensure remove_dead_cohort wasn't called + mock_remove_dead_cohort.assert_not_called() + assert ( + herbivore_cohort_instance.is_alive is True + ) # Cohort should still be alive + + @pytest.mark.parametrize( + "functional_group_type, reproductive_mass, mass_current, birth_mass," + "individuals, is_semelparous, expected_offspring", + [ + # Test case for semelparous organism + ("herbivore", 100.0, 1000.0, 10.0, 5, False, 50), + # Test case for iteroparous organism + ("butterfly", 50.0, 200.0, 0.5, 50, True, 15000), + ], + ) + def test_birth( + self, + animal_model_instance, + herbivore_cohort_instance, + butterfly_cohort_instance, + functional_group_type, + reproductive_mass, + mass_current, + birth_mass, + individuals, + is_semelparous, + expected_offspring, + ): + """Test the birth method with semelparous and iteroparous cohorts.""" + + from uuid import uuid4 + + # Choose the appropriate cohort instance based on the test case + parent_cohort = ( + herbivore_cohort_instance + if functional_group_type == "herbivore" + else butterfly_cohort_instance + ) + + # Mock the attributes of the parent cohort for the test case + parent_cohort.reproductive_mass = reproductive_mass + parent_cohort.mass_current = mass_current + parent_cohort.functional_group.birth_mass = birth_mass + parent_cohort.individuals = individuals + parent_cohort.functional_group.reproductive_type = ( + "semelparous" if is_semelparous else "iteroparous" + ) + parent_cohort.functional_group.offspring_functional_group = ( + parent_cohort.functional_group.name + ) + + # Set a mock cohort ID + cohort_id = uuid4() + parent_cohort.id = cohort_id + + # Add the parent cohort to the model's cohorts dictionary + animal_model_instance.cohorts[cohort_id] = parent_cohort + + # Store the initial number of cohorts in the model + initial_num_cohorts = len(animal_model_instance.cohorts) + + # Call the birth method (without mocking `get_functional_group_by_name`) + animal_model_instance.birth(parent_cohort) + + # Check if the parent cohort is dead (only if semelparous) + if is_semelparous: + assert parent_cohort.is_alive is False + else: + assert parent_cohort.is_alive is True + + # Check that reproductive mass is reset + assert parent_cohort.reproductive_mass == 0.0 + + # Check the number of offspring generated and added to the cohort list + if is_semelparous: + # For semelparous organisms, the parent dies and the offspring cohort + # replaces it + assert ( + len(animal_model_instance.cohorts) == initial_num_cohorts + ), f"Expected {initial_num_cohorts} cohorts but" + " found {len(animal_model_instance.cohorts)}" + else: + # For iteroparous organisms, the parent survives and the offspring is added + assert ( + len(animal_model_instance.cohorts) == initial_num_cohorts + 1 + ), f"Expected {initial_num_cohorts + 1} cohorts but" + " found {len(animal_model_instance.cohorts)}" + + # Get the offspring cohort (assuming it was added correctly) + offspring_cohort = list(animal_model_instance.cohorts.values())[-1] + + # Validate the attributes of the offspring cohort + assert ( + offspring_cohort.functional_group.name + == parent_cohort.functional_group.name + ) + assert ( + offspring_cohort.mass_current == parent_cohort.functional_group.birth_mass + ) + assert offspring_cohort.individuals == expected_offspring diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 88a152913..e4a99425a 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -545,8 +545,17 @@ def birth(self, parent_cohort: AnimalCohort) -> None: # reduce reproductive mass by amount used to generate offspring parent_cohort.reproductive_mass = 0.0 + if number_offspring <= 0: + print("No offspring created, exiting birth method.") + return + + offspring_functional_group = get_functional_group_by_name( + self.functional_groups, + parent_cohort.functional_group.offspring_functional_group, + ) + offspring_cohort = AnimalCohort( - parent_cohort.functional_group, + offspring_functional_group, parent_cohort.functional_group.birth_mass, 0.0, number_offspring, @@ -558,6 +567,9 @@ def birth(self, parent_cohort: AnimalCohort) -> None: # add a new cohort of the parental type to the community self.cohorts[offspring_cohort.id] = offspring_cohort + # Debug: Print cohorts after adding offspring + print(f"Total cohorts after adding offspring: {len(self.cohorts)}") + # add the new cohort to the community lists it occupies self.update_community_occupancy(offspring_cohort, offspring_cohort.centroid_key) From 2fc6a1d5b6f584eeec4a4ae4f14eb51b7cef6e6b Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 30 Sep 2024 13:24:23 +0100 Subject: [PATCH 45/62] Small modification to forage_community, tests for birth community and forage community. --- tests/models/animals/conftest.py | 33 +++++++ tests/models/animals/test_animal_cohorts.py | 33 ------- tests/models/animals/test_animal_model.py | 86 +++++++++++++++++++ .../models/animal/animal_model.py | 27 ++++-- 4 files changed, 137 insertions(+), 42 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 35a23f324..fcac22a9d 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -203,6 +203,39 @@ def herbivore_cohort_instance( ) +@pytest.fixture +def predator_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[2] + + +@pytest.fixture +def predator_cohort_instance( + predator_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( + predator_functional_group_instance, # functional group + 10000.0, # mass + 1, # age + 10, # individuals + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, + ) + + @pytest.fixture def caterpillar_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index d058066e4..dd9d89b46 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -4,39 +4,6 @@ from numpy import isclose, timedelta64 -@pytest.fixture -def predator_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[2] - - -@pytest.fixture -def predator_cohort_instance( - predator_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( - predator_functional_group_instance, # functional group - 10000.0, # mass - 1, # age - 10, # individuals - 1, # centroid - animal_data_for_cohorts_instance.grid, # grid - constants_instance, - ) - - @pytest.fixture def ectotherm_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 0682a2371..449e4476a 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -962,3 +962,89 @@ def test_birth( offspring_cohort.mass_current == parent_cohort.functional_group.birth_mass ) assert offspring_cohort.individuals == expected_offspring + + def test_forage_community( + self, + animal_model_instance, + herbivore_cohort_instance, + predator_cohort_instance, + mocker, + ): + """Test that forage_cohort is called correctly.""" + + from virtual_ecosystem.models.animal.animal_traits import DietType + + # Mock the methods for herbivore and predator cohorts using the mocker fixture + mock_forage_herbivore = mocker.Mock() + mock_forage_predator = mocker.Mock() + mock_get_excrement_pools_herbivore = mocker.Mock( + return_value=["excrement_pools_herbivore"] + ) + mock_get_excrement_pools_predator = mocker.Mock( + return_value=["excrement_pools_predator"] + ) + mock_get_plant_resources = mocker.Mock(return_value=["plant_resources"]) + mock_get_prey = mocker.Mock(return_value=["prey"]) + + # Set up herbivore cohort + herbivore_cohort_instance.functional_group.diet = DietType.HERBIVORE + mocker.patch.object( + herbivore_cohort_instance, "get_plant_resources", mock_get_plant_resources + ) + mocker.patch.object( + herbivore_cohort_instance, "get_prey", mocker.Mock() + ) # Should not be called for herbivores + mocker.patch.object( + herbivore_cohort_instance, + "get_excrement_pools", + mock_get_excrement_pools_herbivore, + ) + mocker.patch.object( + herbivore_cohort_instance, "forage_cohort", mock_forage_herbivore + ) + + # Set up predator cohort + predator_cohort_instance.functional_group.diet = DietType.CARNIVORE + mocker.patch.object( + predator_cohort_instance, "get_plant_resources", mocker.Mock() + ) # Should not be called for predators + mocker.patch.object(predator_cohort_instance, "get_prey", mock_get_prey) + mocker.patch.object( + predator_cohort_instance, + "get_excrement_pools", + mock_get_excrement_pools_predator, + ) + mocker.patch.object( + predator_cohort_instance, "forage_cohort", mock_forage_predator + ) + + # Add cohorts to the animal_model_instance + animal_model_instance.cohorts = { + "herbivore": herbivore_cohort_instance, + "predator": predator_cohort_instance, + } + + # Run the forage_community method + animal_model_instance.forage_community() + + # Verify that herbivores forage plant resources and not animal prey + mock_get_plant_resources.assert_called_once_with( + animal_model_instance.plant_resources + ) + herbivore_cohort_instance.get_prey.assert_not_called() + mock_forage_herbivore.assert_called_once_with( + plant_list=["plant_resources"], + animal_list=[], + excrement_pools=["excrement_pools_herbivore"], + carcass_pools=animal_model_instance.carcass_pools, + ) + + # Verify that predators forage prey and not plant resources + mock_get_prey.assert_called_once_with(animal_model_instance.communities) + predator_cohort_instance.get_plant_resources.assert_not_called() + mock_forage_predator.assert_called_once_with( + plant_list=[], + animal_list=["prey"], + excrement_pools=["excrement_pools_predator"], + carcass_pools=animal_model_instance.carcass_pools, + ) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index e4a99425a..d014aee24 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -33,7 +33,7 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_traits import DevelopmentType +from virtual_ecosystem.models.animal.animal_traits import DevelopmentType, DietType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool from virtual_ecosystem.models.animal.functional_group import ( @@ -592,6 +592,9 @@ def birth_community(self) -> None: def forage_community(self) -> None: """This function organizes the foraging of animal cohorts. + Herbivores will only forage plant resources, while carnivores will forage for + prey (other animal cohorts). + It loops over every animal cohort in the community and calls the forage_cohort function with a list of suitable trophic resources. This action initiates foraging for those resources, with mass transfer details handled @@ -599,20 +602,26 @@ def forage_community(self) -> None: include functions for handling scavenging and soil consumption behaviors. Cohorts with no remaining individuals post-foraging are marked for death. - - TODO: find a more elegant way to remove dead cohorts between foraging bouts - """ for consumer_cohort in self.cohorts.values(): - # Prepare the prey list for the consumer cohort + # Check that the cohort has a valid territory defined if consumer_cohort.territory is None: raise ValueError("The cohort's territory hasn't been defined.") - prey_list = consumer_cohort.get_prey(self.communities) - plant_list = consumer_cohort.get_plant_resources(self.plant_resources) + + # Initialize empty resource lists + plant_list = [] + prey_list = [] excrement_list = consumer_cohort.get_excrement_pools(self.excrement_pools) - # Initiate foraging for the consumer cohort with the prepared resources + # Check the diet of the cohort and get appropriate resources + if consumer_cohort.functional_group.diet == DietType.HERBIVORE: + plant_list = consumer_cohort.get_plant_resources(self.plant_resources) + + elif consumer_cohort.functional_group.diet == DietType.CARNIVORE: + prey_list = consumer_cohort.get_prey(self.communities) + + # Initiate foraging for the consumer cohort with the available resources consumer_cohort.forage_cohort( plant_list=plant_list, animal_list=prey_list, @@ -620,7 +629,7 @@ def forage_community(self) -> None: carcass_pools=self.carcass_pools, # the full list of carcass pools ) - # temporary solution + # Temporary solution to remove dead cohorts self.remove_dead_cohort_community() def metabolize_community( From 6ee1a7b61e93c2aabf9d2421cea0b3e5118a7b14 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 30 Sep 2024 16:51:37 +0100 Subject: [PATCH 46/62] Testing for metamorphose. --- tests/models/animals/test_animal_model.py | 277 ++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 449e4476a..1688092a1 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -1048,3 +1048,280 @@ def test_forage_community( excrement_pools=["excrement_pools_predator"], carcass_pools=animal_model_instance.carcass_pools, ) + + def test_metabolize_community( + self, animal_model_instance, animal_data_for_cohorts_instance, mocker + ): + """Test metabolize_community using real data from fixture.""" + + from numpy import timedelta64 + + # Assign the data from the fixture to the animal model + animal_model_instance.data = animal_data_for_cohorts_instance + air_temperature_data = animal_data_for_cohorts_instance["air_temperature"] + + # Create mock cohorts and their behaviors + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Mock return values for metabolize and respire + mock_cohort_1.metabolize.return_value = ( + 10.0 # Metabolic waste mass for cohort 1 + ) + mock_cohort_2.metabolize.return_value = ( + 15.0 # Metabolic waste mass for cohort 2 + ) + mock_cohort_1.respire.return_value = 5.0 # Carbonaceous waste for cohort 1 + mock_cohort_2.respire.return_value = 8.0 # Carbonaceous waste for cohort 2 + + # Setup the community and excrement pools in the animal model + animal_model_instance.communities = { + 1: [mock_cohort_1, mock_cohort_2], # Community in cell 1 with two cohorts + 2: [], # Empty community in cell 2 + } + animal_model_instance.excrement_pools = { + 1: "excrement_pool_1", + 2: "excrement_pool_2", + } + + # Run the metabolize_community method + dt = timedelta64(1, "D") # 1 day as the time delta + animal_model_instance.metabolize_community(air_temperature_data, dt) + + # Assertions for the first cohort in cell 1 + mock_cohort_1.metabolize.assert_called_once_with( + 25.0, dt + ) # Temperature for cell 1 from the fixture (25.0) + mock_cohort_1.respire.assert_called_once_with( + 10.0 + ) # Metabolic waste returned by metabolize + mock_cohort_1.excrete.assert_called_once_with(10.0, "excrement_pool_1") + + # Assertions for the second cohort in cell 1 + mock_cohort_2.metabolize.assert_called_once_with( + 25.0, dt + ) # Temperature for cell 1 from the fixture (25.0) + mock_cohort_2.respire.assert_called_once_with( + 15.0 + ) # Metabolic waste returned by metabolize + mock_cohort_2.excrete.assert_called_once_with(15.0, "excrement_pool_1") + + # Assert total animal respiration was updated for cell 1 + total_animal_respiration = animal_model_instance.data[ + "total_animal_respiration" + ] + assert total_animal_respiration.loc[{"cell_id": 1}] == 13.0 # 5.0 + 8.0 + + # Ensure no cohort methods were called for the empty community in cell 2 + mock_cohort_1.reset_mock() + mock_cohort_2.reset_mock() + mock_cohort_1.metabolize.assert_not_called() + mock_cohort_2.metabolize.assert_not_called() + + def test_increase_age_community(self, animal_model_instance, mocker): + """Test increase_age.""" + + from numpy import timedelta64 + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + } + + # Define the time delta + dt = timedelta64(10, "D") # 10 days + + # Run the increase_age_community method + animal_model_instance.increase_age_community(dt) + + # Assert that increase_age was called with the correct time delta + mock_cohort_1.increase_age.assert_called_once_with(dt) + mock_cohort_2.increase_age.assert_called_once_with(dt) + + def test_inflict_non_predation_mortality_community( + self, animal_model_instance, mocker + ): + """Test inflict_non_predation_mortality_community.""" + + from numpy import timedelta64 + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + } + + # Mock return values for cohort methods + mock_cohort_1.get_carcass_pools.return_value = "carcass_pool_1" + mock_cohort_2.get_carcass_pools.return_value = "carcass_pool_2" + + # Define the number of individuals + mock_cohort_1.individuals = 100 + mock_cohort_2.individuals = 0 # This cohort should be marked as dead + + # Mock the remove_dead_cohort method + mock_remove_dead_cohort = mocker.patch.object( + animal_model_instance, "remove_dead_cohort" + ) + + # Define the time delta + dt = timedelta64(10, "D") # 10 days + + # Run the inflict_non_predation_mortality_community method + animal_model_instance.inflict_non_predation_mortality_community(dt) + + # Calculate the number of days from dt + number_of_days = float(dt / timedelta64(1, "D")) + + # Assert that inflict_non_predation_mortality called with the correct arguments + mock_cohort_1.inflict_non_predation_mortality.assert_called_once_with( + number_of_days, "carcass_pool_1" + ) + mock_cohort_2.inflict_non_predation_mortality.assert_called_once_with( + number_of_days, "carcass_pool_2" + ) + + # Assert that remove_dead_cohort was called for the cohort with zero individuals + mock_remove_dead_cohort.assert_called_once_with(mock_cohort_2) + + # Ensure that the cohort with zero individuals is marked as dead + assert mock_cohort_2.is_alive is False + + # Ensure that the cohort with individuals is not marked as dead + assert mock_cohort_1.is_alive is not False + + def test_metamorphose( + self, + animal_model_instance, + caterpillar_cohort_instance, + ): + """Test metamorphose. + + TODO: add broader assertions + + + """ + + from math import ceil + + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Clear the cohorts list to ensure it is empty + animal_model_instance.cohorts = {} + + # Add the caterpillar cohort to the animal model's cohorts + animal_model_instance.cohorts[caterpillar_cohort_instance.id] = ( + caterpillar_cohort_instance + ) + + # Set the larval cohort (caterpillar) properties + caterpillar_cohort_instance.functional_group.offspring_functional_group = ( + "butterfly" + ) + + initial_individuals = 100 + caterpillar_cohort_instance.individuals = initial_individuals + + # Calculate the expected number of individuals lost due to mortality + number_dead = ceil( + initial_individuals + * caterpillar_cohort_instance.constants.metamorph_mortality + ) + + # Set up functional groups in the animal model instance + butterfly_functional_group = get_functional_group_by_name( + animal_model_instance.functional_groups, + caterpillar_cohort_instance.functional_group.offspring_functional_group, + ) + + # Ensure the butterfly functional group is found + assert ( + butterfly_functional_group is not None + ), "Butterfly functional group not found" + + # Run the metamorphose method on the caterpillar cohort + animal_model_instance.metamorphose(caterpillar_cohort_instance) + + # Assert that the number of individuals in the caterpillar cohort was reduced + assert ( + caterpillar_cohort_instance.individuals == initial_individuals - number_dead + ), "Caterpillar cohort's individuals count is incorrect after metamorphosis" + + # Assert that a new butterfly cohort was created from the caterpillar + adult_cohort = next( + ( + cohort + for cohort in animal_model_instance.cohorts.values() + if cohort.functional_group == butterfly_functional_group + ), + None, + ) + assert adult_cohort is not None, "Butterfly cohort was not created" + + # Assert that the number of individuals in the butterfly cohort is correct + assert ( + adult_cohort.individuals == caterpillar_cohort_instance.individuals + ), "Butterfly cohort's individuals count does not match the expected value" + + # Assert that the caterpillar cohort is marked as dead and removed + assert ( + not caterpillar_cohort_instance.is_alive + ), "Caterpillar cohort should be marked as dead" + assert ( + caterpillar_cohort_instance not in animal_model_instance.cohorts.values() + ), "Caterpillar cohort should be removed from the model" + + def test_metamorphose_community(self, animal_model_instance, mocker): + """Test metamorphose_community.""" + + from virtual_ecosystem.models.animal.animal_traits import DevelopmentType + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + mock_cohort_3 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + "cohort_3": mock_cohort_3, + } + + # Set the properties for each cohort + mock_cohort_1.functional_group.development_type = DevelopmentType.INDIRECT + mock_cohort_1.mass_current = 20.0 + mock_cohort_1.functional_group.adult_mass = 15.0 # Ready for metamorphosis + + mock_cohort_2.functional_group.development_type = DevelopmentType.INDIRECT + mock_cohort_2.mass_current = 10.0 + mock_cohort_2.functional_group.adult_mass = 15.0 # Not ready for metamorphosis + + mock_cohort_3.functional_group.development_type = DevelopmentType.DIRECT + mock_cohort_3.mass_current = 20.0 + mock_cohort_3.functional_group.adult_mass = ( + 15.0 # Direct development, should not metamorphose + ) + + # Mock the metamorphose method + mock_metamorphose = mocker.patch.object(animal_model_instance, "metamorphose") + + # Run the metamorphose_community method + animal_model_instance.metamorphose_community() + + # Assert that metamorphose was called only for cohort that is ready and indirect + mock_metamorphose.assert_called_once_with(mock_cohort_1) + + # Assert that the other cohorts did not trigger metamorphosis + mock_metamorphose.assert_called_once() # Ensure it was called exactly once From d4df50fc55c43032654fa29aa769fd3aad64a453 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 3 Oct 2024 12:53:54 +0100 Subject: [PATCH 47/62] Updated a number of animal data fixtures for currently abiotic structure as well as updated related animal model tests. --- tests/models/animals/conftest.py | 361 +++++++- .../models/animals/test_animal_communities.py | 820 ------------------ tests/models/animals/test_animal_model.py | 141 +-- .../models/animals/test_animal_territories.py | 168 ---- .../models/animal/animal_model.py | 17 +- 5 files changed, 389 insertions(+), 1118 deletions(-) delete mode 100644 tests/models/animals/test_animal_communities.py delete mode 100644 tests/models/animals/test_animal_territories.py diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index fcac22a9d..86a2c68b5 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -80,6 +80,363 @@ def animal_data_for_model_instance(fixture_core_components): return data +@pytest.fixture +def animal_fixture_config(): + """Simple configuration fixture for use in tests.""" + + from virtual_ecosystem.core.config import Config + + cfg_string = """ + [core] + [core.grid] + cell_nx = 3 + cell_ny = 3 + [core.timing] + start_date = "2020-01-01" + update_interval = "2 weeks" + run_length = "50 years" + [core.data_output_options] + save_initial_state = true + save_final_state = true + out_initial_file_name = "model_at_start.nc" + out_final_file_name = "model_at_end.nc" + + [core.layers] + canopy_layers = 10 + soil_layers = [-0.5, -1.0] + above_canopy_height_offset = 2.0 + surface_layer_height = 0.1 + + [plants] + a_plant_integer = 12 + [[plants.ftypes]] + pft_name = "shrub" + max_height = 1.0 + [[plants.ftypes]] + pft_name = "broadleaf" + max_height = 50.0 + + [[animal.functional_groups]] + name = "carnivorous_bird" + taxa = "bird" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.1 + adult_mass = 1.0 + [[animal.functional_groups]] + name = "herbivorous_bird" + taxa = "bird" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.05 + adult_mass = 0.5 + [[animal.functional_groups]] + name = "carnivorous_mammal" + taxa = "mammal" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 4.0 + adult_mass = 40.0 + [[animal.functional_groups]] + name = "herbivorous_mammal" + taxa = "mammal" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 1.0 + adult_mass = 10.0 + [[animal.functional_groups]] + name = "carnivorous_insect" + taxa = "insect" + diet = "carnivore" + metabolic_type = "ectothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.001 + adult_mass = 0.01 + [[animal.functional_groups]] + name = "herbivorous_insect" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "butterfly" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "indirect" + development_status = "adult" + offspring_functional_group = "caterpillar" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "caterpillar" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "nonreproductive" + development_type = "indirect" + development_status = "larval" + offspring_functional_group = "butterfly" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + + [hydrology] + """ + + return Config(cfg_strings=cfg_string) + + +@pytest.fixture +def animal_fixture_core_components(animal_fixture_config): + """A CoreComponents instance for use in testing.""" + from virtual_ecosystem.core.core_components import CoreComponents + + core_components = CoreComponents(animal_fixture_config) + + # Setup three filled canopy layers + canopy_array = np.full( + (core_components.layer_structure.n_canopy_layers, core_components.grid.n_cells), + np.nan, + ) + canopy_array[np.array([0, 1, 2])] = 1.0 + core_components.layer_structure.set_filled_canopy(canopy_array) + + return core_components + + +@pytest.fixture +def dummy_animal_data(animal_fixture_core_components): + """Creates a dummy climate data object for use in tests.""" + + from virtual_ecosystem.core.data import Data + + # Setup the data object with nine cells. + data = Data(animal_fixture_core_components.grid) + + # Shorten syntax + lyr_str = animal_fixture_core_components.layer_structure + from_template = lyr_str.from_template + + # Reference data with a time series + ref_values = { + "air_temperature_ref": 30.0, + "wind_speed_ref": 1.0, + "relative_humidity_ref": 90.0, + "vapour_pressure_deficit_ref": 0.14, + "vapour_pressure_ref": 0.14, + "atmospheric_pressure_ref": 96.0, + "atmospheric_co2_ref": 400.0, + "precipitation": 200.0, + "topofcanopy_radiation": 100.0, + } + + for var, value in ref_values.items(): + data[var] = DataArray( + np.full((9, 3), value), # Update to 9 grid cells + dims=["cell_id", "time_index"], + ) + + # Spatially varying but not vertically structured + spatially_variable = { + "shortwave_radiation_surface": [ + 100, + 10, + 0, + 0, + 50, + 30, + 20, + 15, + 5, + ], # Updated to 9 values + "sensible_heat_flux_topofcanopy": [ + 100, + 50, + 10, + 10, + 40, + 20, + 15, + 12, + 6, + ], # Updated + "friction_velocity": [12, 5, 2, 2, 7, 4, 3, 2.5, 1.5], # Updated + "soil_evaporation": [ + 0.001, + 0.01, + 0.1, + 0.1, + 0.05, + 0.03, + 0.02, + 0.015, + 0.008, + ], # Updated + "surface_runoff_accumulated": [0, 10, 300, 300, 100, 50, 20, 15, 5], # Updated + "subsurface_flow_accumulated": [10, 10, 30, 30, 20, 15, 12, 10, 8], # Updated + "elevation": [200, 100, 10, 10, 80, 60, 40, 30, 15], # Updated + } + for var, vals in spatially_variable.items(): + data[var] = DataArray(vals, dims=["cell_id"]) + + # Spatially constant and not vertically structured + spatially_constant = { + "sensible_heat_flux_soil": 1, + "latent_heat_flux_soil": 1, + "zero_displacement_height": 20.0, + "diabatic_correction_heat_above": 0.1, + "diabatic_correction_heat_canopy": 1.0, + "diabatic_correction_momentum_above": 0.1, + "diabatic_correction_momentum_canopy": 1.0, + "mean_mixing_length": 1.3, + "aerodynamic_resistance_surface": 12.5, + "mean_annual_temperature": 20.0, + } + for var, val in spatially_constant.items(): + data[var] = DataArray( + np.repeat(val, 9), dims=["cell_id"] + ) # Update to 9 grid cells + + # Structural variables - assign values to vertical layer indices across grid id + data["leaf_area_index"] = from_template() + data["leaf_area_index"][lyr_str.index_filled_canopy] = 1.0 + + data["canopy_absorption"] = from_template() + data["canopy_absorption"][lyr_str.index_filled_canopy] = 1.0 + + data["layer_heights"] = from_template() + data["layer_heights"][lyr_str.index_filled_atmosphere] = np.array( + [32.0, 30.0, 20.0, 10.0, lyr_str.surface_layer_height] + )[:, None] + + data["layer_heights"][lyr_str.index_all_soil] = lyr_str.soil_layer_depths[:, None] + + # Microclimate and energy balance + # - Vertically structured + data["wind_speed"] = from_template() + data["wind_speed"][lyr_str.index_filled_atmosphere] = 0.1 + + data["atmospheric_pressure"] = from_template() + data["atmospheric_pressure"][lyr_str.index_filled_atmosphere] = 96.0 + + data["air_temperature"] = from_template() + data["air_temperature"][lyr_str.index_filled_atmosphere] = np.array( + [30.0, 29.844995, 28.87117, 27.206405, 16.145945] + )[:, None] + + data["soil_temperature"] = from_template() + data["soil_temperature"][lyr_str.index_all_soil] = 20.0 + + data["relative_humidity"] = from_template() + data["relative_humidity"][lyr_str.index_filled_atmosphere] = np.array( + [90.0, 90.341644, 92.488034, 96.157312, 100] + )[:, None] + + data["absorbed_radiation"] = from_template() + data["absorbed_radiation"][lyr_str.index_filled_canopy] = 10.0 + + flux_index = np.logical_or(lyr_str.index_above, lyr_str.index_flux_layers) + + data["sensible_heat_flux"] = from_template() + data["sensible_heat_flux"][flux_index] = 0.0 + + data["latent_heat_flux"] = from_template() + data["latent_heat_flux"][flux_index] = 0.0 + + data["molar_density_air"] = from_template() + data["molar_density_air"][lyr_str.index_filled_atmosphere] = 38.0 + + data["specific_heat_air"] = from_template() + data["specific_heat_air"][lyr_str.index_filled_atmosphere] = 29.0 + + data["attenuation_coefficient"] = from_template() + data["attenuation_coefficient"][lyr_str.index_filled_atmosphere] = np.array( + [13.0, 13.0, 13.0, 13.0, 2.0] + )[:, None] + + data["relative_turbulence_intensity"] = from_template() + data["relative_turbulence_intensity"][lyr_str.index_filled_atmosphere] = np.array( + [17.64, 16.56, 11.16, 5.76, 0.414] + )[:, None] + + data["latent_heat_vapourisation"] = from_template() + data["latent_heat_vapourisation"][lyr_str.index_filled_atmosphere] = 2254.0 + + data["canopy_temperature"] = from_template() + data["canopy_temperature"][lyr_str.index_filled_canopy] = 25.0 + + data["leaf_air_heat_conductivity"] = from_template() + data["leaf_air_heat_conductivity"][lyr_str.index_filled_canopy] = 0.13 + + data["leaf_vapour_conductivity"] = from_template() + data["leaf_vapour_conductivity"][lyr_str.index_filled_canopy] = 0.2 + + data["conductivity_from_ref_height"] = from_template() + data["conductivity_from_ref_height"][ + np.logical_or(lyr_str.index_filled_canopy, lyr_str.index_surface) + ] = 3.0 + + data["stomatal_conductance"] = from_template() + data["stomatal_conductance"][lyr_str.index_filled_canopy] = 15.0 + + # Hydrology + data["evapotranspiration"] = from_template() + data["evapotranspiration"][lyr_str.index_filled_canopy] = 20.0 + + data["soil_moisture"] = from_template() + data["soil_moisture"][lyr_str.index_all_soil] = np.array([5.0, 500.0])[:, None] + + data["groundwater_storage"] = DataArray( + np.full((2, 9), 450.0), + dims=("groundwater_layers", "cell_id"), + ) + + # Initialize total_animal_respiration with zeros for each cell + total_animal_respiration = np.zeros( + len(animal_fixture_core_components.grid.cell_id) + ) + data["total_animal_respiration"] = DataArray( + total_animal_respiration, + dims=["cell_id"], + coords={"cell_id": animal_fixture_core_components.grid.cell_id}, + ) + + return data + + @pytest.fixture def animal_data_for_cohorts_instance(fixture_core_components): """Fixture returning a combination of plant and air temperature data.""" @@ -153,7 +510,7 @@ def functional_group_list_instance(shared_datadir, constants_instance): @pytest.fixture def animal_model_instance( - animal_data_for_model_instance, + dummy_animal_data, fixture_core_components, functional_group_list_instance, constants_instance, @@ -163,7 +520,7 @@ def animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel return AnimalModel( - data=animal_data_for_model_instance, + data=dummy_animal_data, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py deleted file mode 100644 index 6690a472e..000000000 --- a/tests/models/animals/test_animal_communities.py +++ /dev/null @@ -1,820 +0,0 @@ -"""Test module for animal_communities.py.""" - -from math import ceil - -import pytest -from pytest_mock import MockerFixture - - -@pytest.fixture -def animal_community_destination_instance( - functional_group_list_instance, - animal_model_instance, - animal_data_for_community_instance, - constants_instance, -): - """Fixture for an animal community used in tests.""" - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity - - return AnimalCommunity( - functional_groups=functional_group_list_instance, - data=animal_data_for_community_instance, - community_key=5, - neighbouring_keys=[2, 8, 4, 6], - get_community_by_key=animal_model_instance.get_community_by_key, - constants=constants_instance, - ) - - -@pytest.fixture -def 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[3] - - -@pytest.fixture -def animal_cohort_instance( - functional_group_instance, animal_territory_instance, constants_instance -): - """Fixture for an animal cohort used in tests.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - return AnimalCohort( - functional_group_instance, - functional_group_instance.adult_mass, - 1.0, - 10, - animal_territory_instance, - constants_instance, - ) - - -@pytest.fixture -def mock_animal_territory(mocker): - """Mock fixture for animal territory.""" - return mocker.patch( - "virtual_ecosystem.models.animal.animal_territories.AnimalTerritory" - ) - - -@pytest.fixture -def mock_bfs_territory(mocker): - """Mock fixture for the bfs_territory function.""" - return mocker.patch( - "virtual_ecosystem.models.animal.animal_territories.bfs_territory" - ) - - -class TestAnimalCommunity: - """Test AnimalCommunity class.""" - - def test_initialization(self, animal_community_instance): - """Testing initialization of derived parameters for animal cohorts.""" - assert list(animal_community_instance.animal_cohorts.keys()) == [ - "carnivorous_bird", - "herbivorous_bird", - "carnivorous_mammal", - "herbivorous_mammal", - "carnivorous_insect_iteroparous", - "herbivorous_insect_iteroparous", - "carnivorous_insect_semelparous", - "herbivorous_insect_semelparous", - "butterfly", - "caterpillar", - ] - - def test_all_animal_cohorts_property( - self, animal_community_instance, animal_cohort_instance - ): - """Test the all_animal_cohorts property.""" - - # Add an animal cohort to the community - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - animal_community_instance.occupancy["herbivorous_mammal"][ - animal_cohort_instance - ] = 1.0 - - # Check if the added cohort is in the all_animal_cohorts property - assert animal_cohort_instance in animal_community_instance.all_animal_cohorts - - def test_populate_community(self, animal_community_instance): - """Testing populate_community.""" - animal_community_instance.populate_community() - for cohorts in animal_community_instance.animal_cohorts.values(): - assert len(cohorts) == 1 # since it should have populated one of each - - def test_migrate( - self, - animal_cohort_instance, - animal_community_instance, - animal_community_destination_instance, - ): - """Testing migrate.""" - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - animal_community_instance.migrate( - animal_community_instance.animal_cohorts["herbivorous_mammal"][0], - animal_community_destination_instance, - ) - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - assert ( - animal_cohort_instance - in animal_community_destination_instance.animal_cohorts[ - "herbivorous_mammal" - ] - ) - - @pytest.mark.parametrize( - "mass_ratio, age, probability_output, should_migrate", - [ - (0.5, 5.0, False, True), # Starving non-juvenile, should migrate - ( - 1.0, - 0.0, - False, - False, - ), # Well-fed juvenile, low probability, should not migrate - ( - 1.0, - 0.0, - True, - True, - ), # Well-fed juvenile, high probability, should migrate - ( - 0.5, - 0.0, - True, - True, - ), # Starving juvenile, high probability, should migrate - ( - 0.5, - 0.0, - False, - True, - ), # Starving juvenile, low probability, should migrate due to starvation - (1.0, 5.0, False, False), # Well-fed non-juvenile, should not migrate - ], - ids=[ - "starving_non_juvenile", - "well_fed_juvenile_low_prob", - "well_fed_juvenile_high_prob", - "starving_juvenile_high_prob", - "starving_juvenile_low_prob", - "well_fed_non_juvenile", - ], - ) - def test_migrate_community( - self, - mocker, - animal_community_instance, - animal_community_destination_instance, - animal_cohort_instance, - mass_ratio, - age, - probability_output, - should_migrate, - ): - """Test migration of cohorts for both starving and juvenile conditions.""" - - cohort = animal_cohort_instance - cohort.age = age - cohort.mass_current = cohort.functional_group.adult_mass * mass_ratio - - # Mock the get_community_by_key method to return the destination community. - mocker.patch.object( - animal_community_instance, - "get_community_by_key", - return_value=animal_community_destination_instance, - ) - - # Append cohort to the source community based on the functional group name - functional_group_name = cohort.functional_group.name - animal_community_instance.animal_cohorts[functional_group_name].append(cohort) - - # Mock `migrate_juvenile_probability` to control juvenile migration logic - mocker.patch.object( - cohort, "migrate_juvenile_probability", return_value=probability_output - ) - - # Perform the migration - animal_community_instance.migrate_community() - - # Check migration outcome based on expected results - if should_migrate: - assert ( - cohort not in animal_community_instance.animal_cohorts[cohort.name] - ), f"Cohort {cohort} should have migrated but didn't." - assert ( - cohort - in animal_community_destination_instance.animal_cohorts[cohort.name] - ), f"Cohort {cohort} should be in the destination community but isn't." - else: - assert ( - cohort in animal_community_instance.animal_cohorts[cohort.name] - ), f"Cohort {cohort} should have stayed but migrated." - - @pytest.mark.parametrize( - "reproductive_type, initial_mass, expected_offspring", - [ - pytest.param("iteroparous", 10, 1, id="iteroparous_survival"), - pytest.param("semelparous", 10, 1, id="semelparous_death"), - ], - ) - def test_birth( - self, - reproductive_type, - initial_mass, - expected_offspring, - animal_community_instance, - animal_cohort_instance, - ): - """Test the birth method in AnimalCommunity under various conditions.""" - - # Setup initial conditions - parent_cohort_name = animal_cohort_instance.name - animal_cohort_instance.functional_group.reproductive_type = reproductive_type - animal_cohort_instance.functional_group.birth_mass = 2 - animal_cohort_instance.mass_current = initial_mass - animal_cohort_instance.individuals = 10 - - # Prepare the community - animal_community_instance.animal_cohorts[parent_cohort_name] = [ - animal_cohort_instance - ] - - number_cohorts = len( - animal_community_instance.animal_cohorts[parent_cohort_name] - ) - - animal_community_instance.birth(animal_cohort_instance) - - # Assertions - # 1. Check for changes in the parent cohort based on reproductive type - if reproductive_type == "semelparous": - # The parent should be removed if it dies - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts[parent_cohort_name] - ) - else: - # Reproductive mass should be reset - assert animal_cohort_instance.reproductive_mass == 0 - # The parent should still be present in the community - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts[parent_cohort_name] - ) - - # 2. Check that the offspring were added if reproduction occurred - - if expected_offspring and reproductive_type == "semelparous": - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts - ) - elif expected_offspring and reproductive_type == "iteroparous": - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts + 1 - ) - else: - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts - ) - - def test_birth_community(self, animal_community_instance, constants_instance): - """Test the thresholding behavior of birth_community.""" - - from itertools import chain - - # Preparation: populate the community - animal_community_instance.populate_community() - - # Choose a cohort to track - all_cohorts = list( - chain.from_iterable(animal_community_instance.animal_cohorts.values()) - ) - initial_cohort = all_cohorts[0] - - # Set mass to just below the threshold - threshold_mass = ( - initial_cohort.functional_group.adult_mass - * constants_instance.birth_mass_threshold - - initial_cohort.functional_group.adult_mass - ) - - initial_cohort.reproductive_mass = threshold_mass - 0.1 - initial_count_below_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - - # Execution: apply birth to the community - animal_community_instance.birth_community() - - # Assertion: check if the cohort count remains unchanged - new_count_below_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - assert new_count_below_threshold == initial_count_below_threshold - - # Set mass to just above the threshold - initial_cohort.reproductive_mass = threshold_mass + 0.1 - initial_count_above_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - - # Execution: apply birth to the community again - animal_community_instance.birth_community() - - # Assertion: check if the cohort count increased by 1 for the above case - new_count_above_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - assert new_count_above_threshold == initial_count_above_threshold + 1 - - def test_forage_community( - self, - mocker, - animal_community_instance, - animal_cohort_instance, - animal_territory_instance, - ): - """Test foraging of animal cohorts in a community.""" - - cohort = animal_cohort_instance - cohort.territory = animal_territory_instance - - # Mock the necessary territory methods to return appropriate resources - get_prey_mock = mocker.patch.object( - animal_territory_instance, "get_prey", return_value=[] - ) - get_plant_resources_mock = mocker.patch.object( - animal_territory_instance, "get_plant_resources", return_value=[] - ) - get_excrement_pools_mock = mocker.patch.object( - animal_territory_instance, "get_excrement_pools", return_value=[] - ) - - # Mock the forage_cohort method to simulate foraging - forage_cohort_mock = mocker.patch.object( - cohort, "forage_cohort", return_value=None - ) - - # Append cohort to the source community based on the functional group name - functional_group_name = cohort.functional_group.name - if functional_group_name not in animal_community_instance.animal_cohorts: - animal_community_instance.animal_cohorts[functional_group_name] = [] - animal_community_instance.animal_cohorts[functional_group_name].append(cohort) - - # Perform the foraging - animal_community_instance.forage_community() - - # Check if the helper methods were called correctly - get_prey_mock.assert_called_with(cohort) - get_plant_resources_mock.assert_called_once() - get_excrement_pools_mock.assert_called_once() - forage_cohort_mock.assert_called_with( - plant_list=[], animal_list=[], excrement_pools=[] - ) - - def test_collect_prey_finds_eligible_prey( - self, - animal_cohort_instance, - animal_community_instance, - functional_group_instance, - animal_territory_instance, - constants_instance, - ): - """Testing collect_prey with eligible prey items.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - prey_cohort = AnimalCohort( - functional_group_instance, - 5000.0, - 1, - 10, - animal_territory_instance, - constants_instance, - ) - animal_community_instance.animal_cohorts[functional_group_instance.name].append( - prey_cohort - ) - - animal_cohort_instance.prey_groups = { - functional_group_instance.name: (0, 10000) - } - - collected_prey = animal_community_instance.collect_prey(animal_cohort_instance) - - assert prey_cohort in collected_prey - - def test_collect_prey_filters_out_ineligible_prey( - self, - animal_cohort_instance, - animal_community_instance, - functional_group_instance, - animal_territory_instance, - constants_instance, - ): - """Testing collect_prey with no eligible prey items.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - prey_cohort = AnimalCohort( - functional_group_instance, - 20000.0, - 1, - 10, - animal_territory_instance, - constants_instance, - ) - animal_community_instance.animal_cohorts[functional_group_instance.name].append( - prey_cohort - ) - - animal_cohort_instance.prey_groups = { - functional_group_instance.name: (0, 10000) - } - - collected_prey = animal_community_instance.collect_prey(animal_cohort_instance) - - assert prey_cohort not in collected_prey - - def test_increase_age_community(self, animal_community_instance): - """Testing increase_age_community.""" - from itertools import chain - - from numpy import timedelta64 - - animal_community_instance.populate_community() - - initial_age = next( - iter(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ).age - animal_community_instance.increase_age_community(timedelta64(5, "D")) - new_age = next( - iter(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ).age - assert new_age == initial_age + 5 - - def test_metabolize_community( - self, animal_community_instance, mocker: MockerFixture - ): - """Testing metabolize_community.""" - from itertools import chain - - from numpy import timedelta64 - - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - # Mocking the AnimalCohort methods - mock_metabolize = mocker.patch.object( - AnimalCohort, "metabolize", return_value=100.0 - ) - mock_respire = mocker.patch.object(AnimalCohort, "respire", return_value=90.0) - mock_excrete = mocker.patch.object(AnimalCohort, "excrete") - - # Initial value of total animal respiration - initial_respiration = ( - animal_community_instance.data["total_animal_respiration"] - .loc[{"cell_id": animal_community_instance.community_key}] - .item() - ) - - # Call the metabolize_community method - animal_community_instance.metabolize_community(25.0, timedelta64(5, "D")) - - # Calculate expected respiration after the method call - num_cohorts = len( - list(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ) - expected_total_respiration = initial_respiration + num_cohorts * 90.0 - - # Check that metabolize was called the correct number of times - assert mock_metabolize.call_count == num_cohorts - - # Check that respire was called the correct number of times - assert mock_respire.call_count == num_cohorts - - # Check that excrete was called the correct number of times - assert mock_excrete.call_count == num_cohorts - - # Verify that total_animal_respiration was updated correctly - updated_respiration = ( - animal_community_instance.data["total_animal_respiration"] - .loc[{"cell_id": animal_community_instance.community_key}] - .item() - ) - assert updated_respiration == expected_total_respiration - - @pytest.mark.parametrize( - "days", - [ - pytest.param(1, id="one_day"), - pytest.param(5, id="five_days"), - pytest.param(10, id="ten_days"), - ], - ) - def test_inflict_non_predation_mortality_community( - self, - mocker, - animal_community_instance, - carcass_pool_instance, - days, - ): - """Testing natural mortality infliction for the entire community.""" - from numpy import timedelta64 - - dt = timedelta64(days, "D") - - animal_community_instance.populate_community() - - # Mock the inflict_non_predation_mortality method - mock_mortality = mocker.patch( - "virtual_ecosystem.models.animal.animal_cohorts.AnimalCohort." - "inflict_non_predation_mortality" - ) - - # Ensure the territory carcasses is correctly set to the carcass pool instance - for functional_group in animal_community_instance.animal_cohorts.values(): - for cohort in functional_group: - cohort.territory.territory_carcasses = carcass_pool_instance - - # Call the community method to inflict natural mortality - animal_community_instance.inflict_non_predation_mortality_community(dt) - - number_of_days = float(dt / timedelta64(1, "D")) - - # Assert the inflict_non_predation_mortality method was called for each cohort - for cohorts in animal_community_instance.animal_cohorts.values(): - for cohort in cohorts: - mock_mortality.assert_called_with(number_of_days, carcass_pool_instance) - - # Check if cohorts with no individuals left are flagged as not alive - for cohorts in animal_community_instance.animal_cohorts.values(): - for cohort in cohorts: - if cohort.individuals <= 0: - assert ( - not cohort.is_alive - ), "Cohort with no individuals should be marked as not alive" - assert ( - cohort - not in animal_community_instance.animal_cohorts[ - cohort.functional_group.name - ] - ), "Dead cohort should be removed from the community" - - def test_metamorphose( - self, - mocker, - animal_community_instance, - animal_cohort_instance, - butterfly_cohort_instance, - carcass_pool_instance, - ): - """Test the metamorphose method for different scenarios.""" - - larval_cohort = animal_cohort_instance - larval_cohort.is_alive = True - larval_cohort.territory.territory_carcasses = [carcass_pool_instance] - - adult_functional_group = butterfly_cohort_instance.functional_group - adult_functional_group.birth_mass = 5.0 - mock_get_functional_group_by_name = mocker.patch( - "virtual_ecosystem.models.animal.animal_communities.get_functional_group_by_name", - return_value=adult_functional_group, - ) - animal_community_instance.animal_cohorts["butterfly"] = [] - - mock_remove_dead_cohort = mocker.patch.object( - animal_community_instance, "remove_dead_cohort" - ) - - # Verify - number_dead = ceil( - larval_cohort.individuals * larval_cohort.constants.metamorph_mortality - ) - expected_individuals = larval_cohort.individuals - number_dead - - animal_community_instance.metamorphose(larval_cohort) - - assert not larval_cohort.is_alive - assert len(animal_community_instance.animal_cohorts["butterfly"]) == 1 - assert ( - animal_community_instance.animal_cohorts["butterfly"][0].individuals - == expected_individuals - ) - mock_remove_dead_cohort.assert_called_once_with(larval_cohort) - mock_get_functional_group_by_name.assert_called_once_with( - animal_community_instance.functional_groups, - larval_cohort.functional_group.offspring_functional_group, - ) - - @pytest.mark.parametrize( - "mass_current, expected_caterpillar_count, expected_butterfly_count," - "expected_is_alive", - [ - pytest.param( - 0.9, # Caterpillar mass is below the adult mass threshold - 1, # Caterpillar count should remain the same - 0, # Butterfly count should remain the same - True, # Caterpillar should still be alive - id="Below_mass_threshold", - ), - pytest.param( - 1.1, # Caterpillar mass is above the adult mass threshold - 0, # Caterpillar count should decrease - 1, # Butterfly count should increase - False, # Caterpillar should no longer be alive - id="Above_mass_threshold", - ), - ], - ) - def test_metamorphose_community( - self, - animal_community_instance, - caterpillar_cohort_instance, - carcass_pool_instance, - mass_current, - expected_caterpillar_count, - expected_butterfly_count, - expected_is_alive, - ): - """Test the metamorphosis behavior of metamorphose_community.""" - - # Manually set the mass_current for the caterpillar cohort - caterpillar_cohort = caterpillar_cohort_instance - caterpillar_cohort.mass_current = ( - caterpillar_cohort.functional_group.adult_mass * mass_current - ) - - # Initialize the animal_cohorts with both caterpillar and butterfly entries - animal_community_instance.animal_cohorts = { - "caterpillar": [caterpillar_cohort], - "butterfly": [], - } - - # Ensure the territory carcasses is correctly set to the carcass pool instance - for functional_group in animal_community_instance.animal_cohorts.values(): - for cohort in functional_group: - cohort.territory.territory_carcasses = [carcass_pool_instance] - - # Initial counts - initial_caterpillar_count = len( - animal_community_instance.animal_cohorts.get("caterpillar", []) - ) - initial_butterfly_count = len( - animal_community_instance.animal_cohorts.get("butterfly", []) - ) - - assert initial_caterpillar_count == 1 - assert initial_butterfly_count == 0 - - # Execution: apply metamorphose to the community - animal_community_instance.metamorphose_community() - - # New counts after metamorphosis - new_caterpillar_count = len( - animal_community_instance.animal_cohorts.get("caterpillar", []) - ) - new_butterfly_count = len( - animal_community_instance.animal_cohorts.get("butterfly", []) - ) - - # Assertions - assert new_caterpillar_count == expected_caterpillar_count - assert new_butterfly_count == expected_butterfly_count - assert caterpillar_cohort.is_alive == expected_is_alive - - @pytest.fixture - def mock_bfs_territory(self, mocker): - """Fixture for mocking bfs_territory.""" - return mocker.patch( - "virtual_ecosystem.models.animal.animal_territories.bfs_territory" - ) - - def test_initialize_territory( - self, - mocker, - animal_community_instance, - mock_animal_territory, - mock_bfs_territory, - ): - """Test for initialize territory.""" - - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - from virtual_ecosystem.models.animal.functional_group import FunctionalGroup - - # Create mock instances for dependencies - mock_functional_group = mocker.create_autospec(FunctionalGroup, instance=True) - mock_functional_group.name = "herbivorous_mammal" - - mock_cohort = mocker.create_autospec(AnimalCohort, instance=True) - mock_cohort.territory_size = 4 # Example size - mock_cohort.functional_group = mock_functional_group - centroid_key = 0 - - mock_get_community_by_key = mocker.Mock() - mock_community = mocker.Mock() - mock_community.occupancy = {mock_functional_group.name: {}} - mock_get_community_by_key.return_value = mock_community - - # Set up the mock for bfs_territory to return a predefined set of cells - mock_bfs_territory.return_value = [0, 1, 3, 4] - - # Initialize the AnimalCommunity instance and set up grid dimensions - animal_community_instance.data = mocker.Mock() - animal_community_instance.data.grid.cell_nx = 3 - animal_community_instance.data.grid.cell_ny = 3 - - # Call the method under test - animal_community_instance.initialize_territory( - mock_cohort, centroid_key, mock_get_community_by_key - ) - - # Check that bfs_territory was called with the correct parameters - mock_bfs_territory.assert_called_once_with(centroid_key, 4, 3, 3) - - # Check that AnimalTerritory was instantiated with the correct parameters - mock_animal_territory.assert_called_once_with( - centroid_key, [0, 1, 3, 4], mock_get_community_by_key - ) - - # Check that the territory was assigned to the cohort - assert mock_cohort.territory == mock_animal_territory.return_value - - # Ensure no additional unexpected calls were made - assert mock_bfs_territory.call_count == 1 - assert mock_animal_territory.call_count == 1 - - # Check that the occupancy was updated correctly - occupancy_percentage = 1.0 / len(mock_bfs_territory.return_value) - for cell_key in mock_bfs_territory.return_value: - mock_get_community_by_key.assert_any_call(cell_key) - assert ( - mock_community.occupancy[mock_functional_group.name][mock_cohort] - == occupancy_percentage - ) - - def test_reinitialize_territory( - self, - animal_community_instance, - animal_cohort_instance, - animal_territory_instance, - mocker, - ): - """Testing reinitialize_territory.""" - # Spy on the initialize_territory method within the animal_community_instance - spy_initialize_territory = mocker.spy( - animal_community_instance, "initialize_territory" - ) - - # Spy on the abandon_communities method within the animal_territory_instance - spy_abandon_communities = mocker.spy( - animal_territory_instance, "abandon_communities" - ) - - animal_community_instance.community_key = 0 - - # Mock the get_community_by_key callable - get_community_by_key = mocker.MagicMock() - - # Call the reinitialize_territory method - animal_community_instance.reinitialize_territory( - animal_cohort_instance, - animal_community_instance.community_key, - get_community_by_key, - ) - - # Check if abandon_communities was called once - spy_abandon_communities.assert_called_once_with(animal_cohort_instance) - - # Check if initialize_territory was called with correct arguments - spy_initialize_territory.assert_called_once_with( - animal_cohort_instance, - animal_community_instance.community_key, - get_community_by_key, - ) - - # Check if the territory was updated correctly - assert ( - animal_territory_instance.centroid - == animal_community_instance.community_key - ) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 1688092a1..f51ae1750 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -11,7 +11,7 @@ @pytest.fixture def prepared_animal_model_instance( - animal_data_for_model_instance, + dummy_animal_data, fixture_core_components, functional_group_list_instance, constants_instance, @@ -20,7 +20,7 @@ def prepared_animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel model = AnimalModel( - data=animal_data_for_model_instance, + data=dummy_animal_data, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, @@ -34,7 +34,7 @@ class TestAnimalModel: def test_animal_model_initialization( self, - animal_data_for_model_instance, + dummy_animal_data, fixture_core_components, functional_group_list_instance, constants_instance, @@ -45,7 +45,7 @@ def test_animal_model_initialization( # Initialize model model = AnimalModel( - data=animal_data_for_model_instance, + data=dummy_animal_data, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, @@ -59,118 +59,18 @@ def test_animal_model_initialization( assert isinstance(model.communities, dict) @pytest.mark.parametrize( - "config_string,raises,expected_log_entries", + "raises,expected_log_entries", [ pytest.param( - """[core.timing] - start_date = "2020-01-01" - update_interval = "7 days" - [[animal.functional_groups]] - name = "carnivorous_bird" - taxa = "bird" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.1 - adult_mass = 1.0 - [[animal.functional_groups]] - name = "herbivorous_bird" - taxa = "bird" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.05 - adult_mass = 0.5 - [[animal.functional_groups]] - name = "carnivorous_mammal" - taxa = "mammal" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 4.0 - adult_mass = 40.0 - [[animal.functional_groups]] - name = "herbivorous_mammal" - taxa = "mammal" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 1.0 - adult_mass = 10.0 - [[animal.functional_groups]] - name = "carnivorous_insect" - taxa = "insect" - diet = "carnivore" - metabolic_type = "ectothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.001 - adult_mass = 0.01 - [[animal.functional_groups]] - name = "herbivorous_insect" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "butterfly" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "indirect" - development_status = "adult" - offspring_functional_group = "caterpillar" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "caterpillar" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "nonreproductive" - development_type = "indirect" - development_status = "larval" - offspring_functional_group = "butterfly" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - """, does_not_raise(), ( (INFO, "Initialised animal.AnimalConsts from config"), ( INFO, - "Information required to initialise the animal model " - "successfully extracted.", + "Information required to initialise the animal model" + " successfully extracted.", ), - (INFO, "Adding data array for 'total_animal_respiration'"), + (INFO, "Replacing data array for 'total_animal_respiration'"), (INFO, "Adding data array for 'population_densities'"), (INFO, "Adding data array for 'decomposed_excrement'"), (INFO, "Adding data array for 'decomposed_carcasses'"), @@ -182,25 +82,24 @@ def test_animal_model_initialization( def test_generate_animal_model( self, caplog, - animal_data_for_model_instance, - config_string, + dummy_animal_data, + animal_fixture_config, # Use the config fixture raises, expected_log_entries, ): """Test that the function to initialise the animal model behaves as expected.""" - from virtual_ecosystem.core.config import Config from virtual_ecosystem.core.core_components import CoreComponents from virtual_ecosystem.models.animal.animal_model import AnimalModel - # Build the config object and core components - config = Config(cfg_strings=config_string) + # Build the config object and core components using the fixture + config = animal_fixture_config core_components = CoreComponents(config) caplog.clear() # Check whether model is initialised (or not) as expected with raises: model = AnimalModel.from_config( - data=animal_data_for_model_instance, + data=dummy_animal_data, core_components=core_components, config=config, ) @@ -1050,15 +949,17 @@ def test_forage_community( ) def test_metabolize_community( - self, animal_model_instance, animal_data_for_cohorts_instance, mocker + self, animal_model_instance, dummy_animal_data, mocker ): """Test metabolize_community using real data from fixture.""" from numpy import timedelta64 # Assign the data from the fixture to the animal model - animal_model_instance.data = animal_data_for_cohorts_instance - air_temperature_data = animal_data_for_cohorts_instance["air_temperature"] + animal_model_instance.data = dummy_animal_data + air_temperature_data = dummy_animal_data["air_temperature"] + + print(air_temperature_data.shape) # Create mock cohorts and their behaviors mock_cohort_1 = mocker.Mock() @@ -1086,11 +987,11 @@ def test_metabolize_community( # Run the metabolize_community method dt = timedelta64(1, "D") # 1 day as the time delta - animal_model_instance.metabolize_community(air_temperature_data, dt) + animal_model_instance.metabolize_community(dt) # Assertions for the first cohort in cell 1 mock_cohort_1.metabolize.assert_called_once_with( - 25.0, dt + 16.145945, dt ) # Temperature for cell 1 from the fixture (25.0) mock_cohort_1.respire.assert_called_once_with( 10.0 @@ -1099,7 +1000,7 @@ def test_metabolize_community( # Assertions for the second cohort in cell 1 mock_cohort_2.metabolize.assert_called_once_with( - 25.0, dt + 16.145945, dt ) # Temperature for cell 1 from the fixture (25.0) mock_cohort_2.respire.assert_called_once_with( 15.0 diff --git a/tests/models/animals/test_animal_territories.py b/tests/models/animals/test_animal_territories.py deleted file mode 100644 index 2aba829fd..000000000 --- a/tests/models/animals/test_animal_territories.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Test module for animal_territories.py.""" - -import pytest - - -class TestAnimalTerritories: - """For testing the AnimalTerritories class.""" - - @pytest.fixture - def mock_get_plant_resources(self, mocker, animal_territory_instance): - """Mock get_plant_resources method.""" - return mocker.patch.object( - animal_territory_instance, "get_plant_resources", return_value=[] - ) - - @pytest.fixture - def mock_get_excrement_pools(self, mocker, animal_territory_instance): - """Mock get_excrement_pools method.""" - return mocker.patch.object( - animal_territory_instance, "get_excrement_pools", return_value=[] - ) - - @pytest.fixture - def mock_get_carcass_pools(self, mocker, animal_territory_instance): - """Mock get_carcass_pools method.""" - return mocker.patch.object( - animal_territory_instance, "get_carcass_pools", return_value=[] - ) - - def test_update_territory(self, animal_territory_instance): - """Test for update_territory method.""" - # Define new grid cell keys for updating the territory - new_grid_cell_keys = [4, 5, 6] - - # Call update_territory with new grid cell keys - animal_territory_instance.update_territory(new_grid_cell_keys) - - # Check if the territory was updated correctly - assert animal_territory_instance.grid_cell_keys == new_grid_cell_keys - - def test_get_prey( - self, - mocker, - animal_territory_instance, - herbivore_cohort_instance, - animal_community_instance, - ): - """Test for get_prey method.""" - mock_collect_prey = mocker.patch.object( - animal_community_instance, "collect_prey", return_value=[] - ) - - prey = animal_territory_instance.get_prey(herbivore_cohort_instance) - assert prey == [] - for cell_id in animal_territory_instance.grid_cell_keys: - mock_collect_prey.assert_any_call(herbivore_cohort_instance) - - def test_get_plant_resources(self, animal_territory_instance): - """Test for get_plant_resources method.""" - from virtual_ecosystem.models.animal.plant_resources import PlantResources - - plant_resources = animal_territory_instance.get_plant_resources() - assert len(plant_resources) == len(animal_territory_instance.grid_cell_keys) - for plant in plant_resources: - assert isinstance(plant, PlantResources) - - def test_get_excrement_pools(self, animal_territory_instance): - """Test for get_excrement pools method.""" - from virtual_ecosystem.models.animal.decay import ExcrementPool - - excrement_pools = animal_territory_instance.get_excrement_pools() - assert len(excrement_pools) == len(animal_territory_instance.grid_cell_keys) - for excrement in excrement_pools: - assert isinstance(excrement, ExcrementPool) - - def test_get_carcass_pools(self, animal_territory_instance): - """Test for get carcass pool method.""" - from virtual_ecosystem.models.animal.decay import CarcassPool - - carcass_pools = animal_territory_instance.get_carcass_pools() - assert len(carcass_pools) == len(animal_territory_instance.grid_cell_keys) - for carcass in carcass_pools: - assert isinstance(carcass, CarcassPool) - - @pytest.fixture - def mock_carcass_pool(self, mocker): - """Fixture for a mock CarcassPool.""" - mock_pool = mocker.Mock() - mock_pool.scavengeable_energy = 10000.0 - mock_pool.decomposed_energy = 0.0 - return mock_pool - - @pytest.fixture - def mock_community(self, mocker, mock_carcass_pool): - """Fixture for a mock AnimalCommunity with a carcass pool.""" - community_mock = mocker.Mock() - community_mock.carcass_pool = mock_carcass_pool - return community_mock - - @pytest.fixture - def mock_get_community_by_key(self, mocker, mock_community): - """Fixture for get_community_by_key, returning a mock community.""" - return mocker.Mock(side_effect=lambda key: mock_community) - - @pytest.fixture - def animal_territory_instance_1(self, mock_get_community_by_key): - """Fixture for the first animal territory with mock get_community_by_key.""" - from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory - - return AnimalTerritory( - centroid=0, - grid_cell_keys=[1, 2, 3], - get_community_by_key=mock_get_community_by_key, - ) - - @pytest.fixture - def animal_territory_instance_2(self, mock_get_community_by_key): - """Fixture for the second animal territory with mock get_community_by_key.""" - from virtual_ecosystem.models.animal.animal_territories import AnimalTerritory - - return AnimalTerritory( - centroid=1, - grid_cell_keys=[2, 3, 4], - get_community_by_key=mock_get_community_by_key, - ) - - def test_find_intersecting_carcass_pools( - self, - animal_territory_instance_1, - animal_territory_instance_2, - mock_carcass_pool, - ): - """Test for find_intersecting_carcass_pools method.""" - intersecting_pools = ( - animal_territory_instance_1.find_intersecting_carcass_pools( - animal_territory_instance_2 - ) - ) - - # Since the same mock object is returned, we need to repeat it for the - # expected value. - expected_pools = [mock_carcass_pool, mock_carcass_pool] - assert intersecting_pools == expected_pools - - -@pytest.mark.parametrize( - "centroid_key, target_cell_number, cell_nx, cell_ny, expected", - [ - (0, 1, 3, 3, {0}), # Single cell territory - (4, 5, 3, 3, {4, 3, 5, 1, 7}), # Small territory in the center - (0, 4, 3, 3, {0, 1, 3, 6}), # Territory starting at the corner - (8, 4, 3, 3, {8, 2, 5, 7}), # Territory starting at another corner - (4, 9, 3, 3, {4, 3, 5, 1, 7, 0, 2, 6, 8}), # Full grid territory - ], - ids=[ - "single_cell", - "small_center", - "corner_start", - "another_corner", - "full_grid", - ], -) -def test_bfs_territory(centroid_key, target_cell_number, cell_nx, cell_ny, expected): - """Test bfs_territory with various parameters.""" - from virtual_ecosystem.models.animal.animal_territories import bfs_territory - - result = set(bfs_territory(centroid_key, target_cell_number, cell_nx, cell_ny)) - assert result == expected, f"Expected {expected}, but got {result}" diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index d014aee24..64b60a853 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -89,6 +89,8 @@ def __init__( self._setup_grid_neighbours() """Determine grid square adjacency.""" + self.core_components = core_components + """The core components of the models.""" self.functional_groups = functional_groups """List of functional groups in the model.""" self.model_constants = model_constants @@ -279,7 +281,6 @@ def update(self, time_index: int, **kwargs: Any) -> None: self.birth_community() self.metamorphose_community() self.metabolize_community( - self.data["air_temperature"], self.update_interval_timedelta, ) self.inflict_non_predation_mortality_community(self.update_interval_timedelta) @@ -632,9 +633,7 @@ def forage_community(self) -> None: # Temporary solution to remove dead cohorts self.remove_dead_cohort_community() - def metabolize_community( - self, air_temperature_data: DataArray, dt: timedelta64 - ) -> None: + def metabolize_community(self, dt: timedelta64) -> None: """This handles metabolize for all cohorts in a community. This method generates a total amount of metabolic waste per cohort and passes @@ -660,13 +659,15 @@ def metabolize_community( total_carbonaceous_waste = 0.0 # Extract the temperature for this specific community (cell_id) - temperature_for_cell = float( - air_temperature_data.loc[{"cell_id": cell_id}].values - ) + surface_temperature = self.data["air_temperature"][ + self.core_components.layer_structure.index_surface_scalar + ].to_numpy() + + grid_temperature = surface_temperature[cell_id] for cohort in community: # Calculate metabolic waste based on cohort properties - metabolic_waste_mass = cohort.metabolize(temperature_for_cell, dt) + metabolic_waste_mass = cohort.metabolize(grid_temperature, dt) # Carbonaceous waste from respiration total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) From a1af08acb6d8cc021680f90e76e564764203a5cb Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 4 Oct 2024 11:37:05 +0100 Subject: [PATCH 48/62] Worked recent decay pool updates into refactor with no community. --- virtual_ecosystem/data_variables.toml | 73 +++++- virtual_ecosystem/models/animal/__init__.py | 5 +- .../models/animal/animal_cohorts.py | 12 +- .../models/animal/animal_model.py | 235 +++++++++++++++--- virtual_ecosystem/models/animal/decay.py | 163 ++++++++++-- .../models/animal/plant_resources.py | 2 +- virtual_ecosystem/models/litter/carbon.py | 103 +++++--- .../models/litter/input_partition.py | 2 +- .../models/litter/litter_model.py | 45 ++-- 9 files changed, 512 insertions(+), 128 deletions(-) diff --git a/virtual_ecosystem/data_variables.toml b/virtual_ecosystem/data_variables.toml index 884714f60..4b62423ef 100644 --- a/virtual_ecosystem/data_variables.toml +++ b/virtual_ecosystem/data_variables.toml @@ -735,18 +735,81 @@ variable_type = "float" [[variable]] axis = ["spatial"] -description = "Rate of excrement flow from animals into litter" -name = "decomposed_excrement" +description = "Rate of carbon flow from animal excrement into the soil" +name = "decomposed_excrement_carbon" unit = "kg C m^-3 day^-1" variable_type = "float" [[variable]] axis = ["spatial"] -description = "Rate of decomposed carcass biomass flow from animals into litter" -name = "decomposed_carcasses" +description = "Rate of nitrogen flow from animal excrement into the soil" +name = "decomposed_excrement_nitrogen" +unit = "kg N m^-3 day^-1" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Rate of phosphorus flow from animal excrement into the soil" +name = "decomposed_excrement_phosphorus" +unit = "kg P m^-3 day^-1" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Rate of carbon flow from decomposed animal carcasses into the soil" +name = "decomposed_carcasses_carbon" unit = "kg C m^-3 day^-1" variable_type = "float" +[[variable]] +axis = ["spatial"] +description = "Rate of nitrogen flow from decomposed animal carcasses into the soil" +name = "decomposed_carcasses_nitrogen" +unit = "kg N m^-3 day^-1" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Rate of phosphorus flow from decomposed animal carcasses into the soil" +name = "decomposed_carcasses_phosphorus" +unit = "kg P m^-3 day^-1" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Amount of above-ground metabolic litter that has been consumed by animals" +name = "litter_consumption_above_metabolic" +unit = "kg C m^-2" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Amount of above-ground structural litter that has been consumed by animals" +name = "litter_consumption_above_structural" +unit = "kg C m^-2" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Amount of woody litter that has been consumed by animals" +name = "litter_consumption_woody" +unit = "kg C m^-2" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Amount of below-ground metabolic litter that has been consumed by animals" +name = "litter_consumption_below_metabolic" +unit = "kg C m^-2" +variable_type = "float" + +[[variable]] +axis = ["spatial"] +description = "Amount of below-ground structural litter that has been consumed by animals" +name = "litter_consumption_below_structural" +unit = "kg C m^-2" +variable_type = "float" + [[variable]] axis = ["spatial"] description = "Amount of dead wood produced since last update" @@ -920,4 +983,4 @@ axis = ["spatial"] description = "Amount of enzyme class which breaks down mineral associated organic matter" name = "soil_enzyme_maom" unit = "kg C m^-3" -variable_type = "float" +variable_type = "float" \ No newline at end of file diff --git a/virtual_ecosystem/models/animal/__init__.py b/virtual_ecosystem/models/animal/__init__.py index da46ec962..289c70226 100644 --- a/virtual_ecosystem/models/animal/__init__.py +++ b/virtual_ecosystem/models/animal/__init__.py @@ -20,8 +20,9 @@ module. * The :mod:`~virtual_ecosystem.models.animal.constants` provides a set of dataclasses containing the constants required by the broader animal model. -* The :mod:`~virtual_ecosystem.models.animal.decay` provides a model for - both surface carcasses created by mortality and animal excrement. +* The :mod:`~virtual_ecosystem.models.animal.decay` provides a model for carcasses + created by animal mortality, animal excrement and the litter available for animals to + consume. * The :mod:`~virtual_ecosystem.models.animal.plant_resources` provides the :class:`~virtual_ecosystem.models.animal.plant_resources.PlantResources` class, which provides an API for exposing plant model data via the animal model protocols. diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 38e44beb7..fa93676ff 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -208,10 +208,10 @@ def excrete( for excrement_pool in excrement_pools: # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_energy += ( + excrement_pool.scavengeable_carbon += ( 1 - self.decay_fraction_excrement ) * excreta_mass_per_community - excrement_pool.decomposed_energy += ( + excrement_pool.decomposed_carbon += ( self.decay_fraction_excrement * excreta_mass_per_community ) @@ -269,10 +269,10 @@ def defecate( for excrement_pool in excrement_pools: # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_energy += ( + excrement_pool.scavengeable_carbon += ( 1 - self.decay_fraction_excrement ) * waste_energy_per_community - excrement_pool.decomposed_energy += ( + excrement_pool.decomposed_carbon += ( self.decay_fraction_excrement * waste_energy_per_community ) @@ -344,10 +344,10 @@ def update_carcass_pool( for carcass_pool in carcass_pools: # Update the carcass pool with the remainder - carcass_pool.scavengeable_energy += ( + carcass_pool.scavengeable_carbon += ( 1 - self.decay_fraction_carcasses ) * carcass_mass_per_pool - carcass_pool.decomposed_energy += ( + carcass_pool.decomposed_carbon += ( self.decay_fraction_carcasses * carcass_mass_per_pool ) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 64b60a853..8dd1f8f34 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -35,7 +35,7 @@ from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.animal_traits import DevelopmentType, DietType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool +from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool, LitterPool from virtual_ecosystem.models.animal.functional_group import ( FunctionalGroup, get_functional_group_by_name, @@ -51,12 +51,49 @@ class AnimalModel( model_update_bounds=("1 day", "1 month"), vars_required_for_init=(), vars_populated_by_init=("total_animal_respiration", "population_densities"), - vars_required_for_update=(), - vars_populated_by_first_update=("decomposed_excrement", "decomposed_carcasses"), + vars_required_for_update=( + "litter_pool_above_metabolic", + "litter_pool_above_structural", + "litter_pool_woody", + "litter_pool_below_metabolic", + "litter_pool_below_structural", + "c_n_ratio_above_metabolic", + "c_n_ratio_above_structural", + "c_n_ratio_woody", + "c_n_ratio_below_metabolic", + "c_n_ratio_below_structural", + "c_p_ratio_above_metabolic", + "c_p_ratio_above_structural", + "c_p_ratio_woody", + "c_p_ratio_below_metabolic", + "c_p_ratio_below_structural", + ), + vars_populated_by_first_update=( + "decomposed_excrement_carbon", + "decomposed_excrement_nitrogen", + "decomposed_excrement_phosphorus", + "decomposed_carcasses_carbon", + "decomposed_carcasses_nitrogen", + "decomposed_carcasses_phosphorus", + "litter_consumption_above_metabolic", + "litter_consumption_above_structural", + "litter_consumption_woody", + "litter_consumption_below_metabolic", + "litter_consumption_below_structural", + ), vars_updated=( - "decomposed_excrement", - "decomposed_carcasses", + "decomposed_excrement_carbon", + "decomposed_excrement_nitrogen", + "decomposed_excrement_phosphorus", + "decomposed_carcasses_carbon", + "decomposed_carcasses_nitrogen", + "decomposed_carcasses_phosphorus", "total_animal_respiration", + "litter_consumption_above_metabolic", + "litter_consumption_above_structural", + "litter_consumption_woody", + "litter_consumption_below_metabolic", + "litter_consumption_below_structural", ), ): """A class describing the animal model. @@ -104,16 +141,33 @@ def __init__( for cell_id in self.data.grid.cell_id } """The plant resource pools in the model with associated grid cell ids.""" + # 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 self.excrement_pools: dict[int, list[ExcrementPool]] = { cell_id: [ - ExcrementPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) + ExcrementPool( + scavengeable_carbon=1e-3, + scavengeable_nitrogen=1e-4, + scavengeable_phosphorus=1e-6, + decomposed_carbon=0.0, + decomposed_nitrogen=0.0, + decomposed_phosphorus=0.0, + ) ] for cell_id in self.data.grid.cell_id } """The excrement pools in the model with associated grid cell ids.""" self.carcass_pools: dict[int, list[CarcassPool]] = { cell_id: [ - CarcassPool(scavengeable_energy=10000.0, decomposed_energy=10000.0) + CarcassPool( + scavengeable_carbon=1e-3, + scavengeable_nitrogen=1e-4, + scavengeable_phosphorus=1e-6, + decomposed_carbon=0.0, + decomposed_nitrogen=0.0, + decomposed_phosphorus=0.0, + ) ] for cell_id in self.data.grid.cell_id } @@ -276,6 +330,11 @@ def update(self, time_index: int, **kwargs: Any) -> None: **kwargs: Further arguments to the update method. """ + # TODO: merge problems as community looping is not internal to comm methods + # TODO: These pools are populated but nothing actually gets done with them at + # the moment, this will have to change when scavenging gets introduced + litter_pools = self.populate_litter_pools() + self.forage_community() self.migrate_community() self.birth_community() @@ -288,53 +347,151 @@ def update(self, time_index: int, **kwargs: Any) -> None: self.increase_age_community(self.update_interval_timedelta) # Now that communities have been updated information required to update the - # litter model can be extracted - additions_to_litter = self.calculate_litter_additions() - - # Update the litter pools - self.data.add_from_dict(additions_to_litter) + # soil and litter models can be extracted + additions_to_soil = self.calculate_soil_additions() + litter_consumption = self.calculate_total_litter_consumption(litter_pools) + # Update the data object with the changes to soil and litter pools + self.data.add_from_dict(additions_to_soil | litter_consumption) # Update population densities self.update_population_densities() def cleanup(self) -> None: """Placeholder function for animal model cleanup.""" - def calculate_litter_additions(self) -> dict[str, DataArray]: - """Calculate the how much animal matter should be transferred to the litter.""" + def populate_litter_pools(self) -> dict[str, LitterPool]: + """Populate the litter pools that animals can consume from. - # Find the size of all decomposed excrement and carcass pools - decomposed_excrement = [ - sum( - excrement_pool.decomposed_carbon(self.data.grid.cell_area) - for excrement_pool in excrement_pools - ) - for excrement_pools in self.excrement_pools.values() - ] - decomposed_carcasses = [ - sum( - carcass_pool.decomposed_carbon(self.data.grid.cell_area) - for carcass_pool in carcass_pools + TODO: rework for merge + + """ + + return { + "above_metabolic": LitterPool( + pool_name="above_metabolic", + data=self.data, + cell_area=self.grid.cell_area, + ), + "above_structural": LitterPool( + pool_name="above_structural", + data=self.data, + cell_area=self.grid.cell_area, + ), + "woody": LitterPool( + pool_name="woody", + data=self.data, + cell_area=self.grid.cell_area, + ), + "below_metabolic": LitterPool( + pool_name="below_metabolic", + data=self.data, + cell_area=self.grid.cell_area, + ), + "below_structural": LitterPool( + pool_name="below_structural", + data=self.data, + cell_area=self.grid.cell_area, + ), + } + + def calculate_total_litter_consumption( + self, litter_pools: dict[str, LitterPool] + ) -> dict[str, DataArray]: + """Calculate total animal consumption of each litter pool. + + TODO: rework for merge + + Args: + litter_pools: The full set of animal accessible litter pools. + + Returns: + The total consumption of litter from each pool [kg C m^-2] + """ + + # Find total animal consumption from each pool + total_consumption = { + pool_name: self.data[f"litter_pool_{pool_name}"] + - (litter_pools[pool_name].mass_current / self.data.grid.cell_area) + for pool_name in litter_pools.keys() + } + + return { + f"litter_consumption_{pool_name}": DataArray( + array(total_consumption[pool_name]), dims="cell_id" ) - for carcass_pools in self.carcass_pools.values() - ] + for pool_name in litter_pools.keys() + } + + def calculate_soil_additions(self) -> dict[str, DataArray]: + """Calculate how much animal matter should be transferred to the soil.""" + + nutrients = ["carbon", "nitrogen", "phosphorus"] + + # Find the size of all decomposed excrement and carcass pools, by cell_id + decomposed_excrement = { + nutrient: [ + pool.decomposed_nutrient_per_area( + nutrient=nutrient, grid_cell_area=self.data.grid.cell_area + ) + for cell_id, pools in self.excrement_pools.items() + for pool in pools + ] + for nutrient in nutrients + } + + decomposed_carcasses = { + nutrient: [ + pool.decomposed_nutrient_per_area( + nutrient=nutrient, grid_cell_area=self.data.grid.cell_area + ) + for cell_id, pools in self.carcass_pools.items() + for pool in pools + ] + for nutrient in nutrients + } - # All excrement and carcasses in their respective decomposed subpools are moved - # to the litter model, so stored energy of each subpool is reset to zero - for cell_id in self.communities.keys(): - for excrement_pool in self.excrement_pools[cell_id]: - excrement_pool.decomposed_energy = 0.0 - for carcass_pool in self.carcass_pools[cell_id]: - carcass_pool.decomposed_energy = 0.0 + # Reset all decomposed excrement pools to zero + for excrement_pools in self.excrement_pools.values(): + for excrement_pool in excrement_pools: + excrement_pool.decomposed_carbon = 0.0 + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 + for carcass_pools in self.carcass_pools.values(): + for carcass_pool in carcass_pools: + carcass_pool.decomposed_carbon = 0.0 + carcass_pool.decomposed_nitrogen = 0.0 + carcass_pool.decomposed_phosphorus = 0.0 + + # Create the output DataArray for each nutrient return { - "decomposed_excrement": DataArray( - array(decomposed_excrement) + "decomposed_excrement_carbon": DataArray( + array(decomposed_excrement["carbon"]) + / self.model_timing.update_interval_quantity.to("days").magnitude, + dims="cell_id", + ), + "decomposed_excrement_nitrogen": DataArray( + array(decomposed_excrement["nitrogen"]) + / self.model_timing.update_interval_quantity.to("days").magnitude, + dims="cell_id", + ), + "decomposed_excrement_phosphorus": DataArray( + array(decomposed_excrement["phosphorus"]) + / self.model_timing.update_interval_quantity.to("days").magnitude, + dims="cell_id", + ), + "decomposed_carcasses_carbon": DataArray( + array(decomposed_carcasses["carbon"]) + / self.model_timing.update_interval_quantity.to("days").magnitude, + dims="cell_id", + ), + "decomposed_carcasses_nitrogen": DataArray( + array(decomposed_carcasses["nitrogen"]) / self.model_timing.update_interval_quantity.to("days").magnitude, dims="cell_id", ), - "decomposed_carcasses": DataArray( - array(decomposed_carcasses) + "decomposed_carcasses_phosphorus": DataArray( + array(decomposed_carcasses["phosphorus"]) / self.model_timing.update_interval_quantity.to("days").magnitude, dims="cell_id", ), diff --git a/virtual_ecosystem/models/animal/decay.py b/virtual_ecosystem/models/animal/decay.py index d5b0f6c74..5548d3abf 100644 --- a/virtual_ecosystem/models/animal/decay.py +++ b/virtual_ecosystem/models/animal/decay.py @@ -1,62 +1,179 @@ """The :mod:`~virtual_ecosystem.models.animal.decay` module contains pools which are still potentially forageable by animals but are in the process of -microbial decomposition. And the moment this consists of animal carcasses and excrement. +microbial decomposition. This includes excrement and carcasses that are tracked solely +in the animal module. This also includes plant litter which is mainly tracked in the +`litter` module, but is made available for animal consumption. """ # noqa: D205 from dataclasses import dataclass +from virtual_ecosystem.core.data import Data +from virtual_ecosystem.models.animal.protocols import Consumer + @dataclass class CarcassPool: """This class store information about the carcass biomass in each grid cell.""" - scavengeable_energy: float - """The amount of animal accessible energy in the carcass pool [J].""" + scavengeable_carbon: float + """The amount of animal accessible carbon in the carcass pool [kg C].""" + + decomposed_carbon: float + """The amount of decomposed carbon in the carcass pool [kg C].""" + + scavengeable_nitrogen: float + """The amount of animal accessible nitrogen in the carcass pool [kg N].""" - decomposed_energy: float - """The amount of decomposed energy in the carcass pool [J].""" + decomposed_nitrogen: float + """The amount of decomposed nitrogen in the carcass pool [kg N].""" - def decomposed_carbon(self, grid_cell_area: float) -> float: - """Calculate carbon stored in decomposed carcasses based on the energy. + scavengeable_phosphorus: float + """The amount of animal accessible phosphorus in the carcass pool [kg P].""" - TODO - At the moment this literally just assumes that a kilogram of carbon - contains 10^6 J, in future this needs to be properly parametrised. + decomposed_phosphorus: float + """The amount of decomposed phosphorus in the carcass pool [kg P].""" + + def decomposed_nutrient_per_area( + self, nutrient: str, grid_cell_area: float + ) -> float: + """Convert decomposed carcass nutrient content to mass per area units. Args: + nutrient: The name of the nutrient to calculate for grid_cell_area: The size of the grid cell [m^2] + Raises: + AttributeError: If a nutrient other than carbon, nitrogen, or phosphorus is + chosen + Returns: - The amount of decomposed carcass biomass in carbon terms [kg C m^-2] + The nutrient content of the decomposed carcasses on a per area basis [kg + m^-2] """ - joules_per_kilo_carbon = 1e6 + decomposed_nutrient = getattr(self, f"decomposed_{nutrient}") - return self.decomposed_energy / (joules_per_kilo_carbon * grid_cell_area) + return decomposed_nutrient / grid_cell_area @dataclass class ExcrementPool: """This class store information about the amount of excrement in each grid cell.""" - scavengeable_energy: float - """The amount of animal accessible energy in the excrement pool [J].""" + scavengeable_carbon: float + """The amount of animal accessible carbon in the excrement pool [kg C].""" - decomposed_energy: float - """The amount of decomposed energy in the excrement pool [J].""" + decomposed_carbon: float + """The amount of decomposed carbon in the excrement pool [kg C].""" - def decomposed_carbon(self, grid_cell_area: float) -> float: - """Calculate carbon stored in decomposed excrement based on the energy. + scavengeable_nitrogen: float + """The amount of animal accessible nitrogen in the excrement pool [kg N].""" - TODO - At the moment this literally just assumes that a kilogram of carbon - contains 10^6 J, in future this needs to be properly parametrised. + decomposed_nitrogen: float + """The amount of decomposed nitrogen in the excrement pool [kg N].""" + + scavengeable_phosphorus: float + """The amount of animal accessible phosphorus in the excrement pool [kg P].""" + + decomposed_phosphorus: float + """The amount of decomposed phosphorus in the excrement pool [kg P].""" + + def decomposed_nutrient_per_area( + self, nutrient: str, grid_cell_area: float + ) -> float: + """Convert decomposed excrement nutrient content to mass per area units. Args: + nutrient: The name of the nutrient to calculate for grid_cell_area: The size of the grid cell [m^2] + Raises: + AttributeError: If a nutrient other than carbon, nitrogen, or phosphorus is + chosen + Returns: - The amount of decomposed excrement in carbon terms [kg C m^-2] + The nutrient content of the decomposed excrement on a per area basis [kg + m^-2] """ - joules_per_kilo_carbon = 1e6 + decomposed_nutrient = getattr(self, f"decomposed_{nutrient}") + + return decomposed_nutrient / grid_cell_area + + +def find_decay_consumed_split( + microbial_decay_rate: float, animal_scavenging_rate: float +): + """Find fraction of biomass that is assumed to decay rather than being scavenged. + + This should be calculated separately for each relevant biomass type (excrement and + carcasses). This function should could be replaced in future by something that + incorporates more of the factors determining this split (e.g. temperature). + + Args: + microbial_decay_rate: Rate at which biomass type decays due to microbes [day^-1] + animal_scavenging_rate: Rate at which biomass type is scavenged due to animals + [day^-1] + """ + + return microbial_decay_rate / (animal_scavenging_rate + microbial_decay_rate) + + +class LitterPool: + """A class that makes litter available for animal consumption. + + This class acts as the interface between litter model data stored in the core data + object and the animal model. + + This class is designed to be reused for all five of the litter pools used in the + litter model, as all of these pools are consumable by animals. + + Args: + pool_name: The name of the litter pool being accessed. + data: A Data object containing information from the litter model. + cell_area: The size of the cell, used to convert from density to mass units + [m^2] + """ + + def __init__(self, pool_name: str, data: Data, cell_area: float) -> None: + self.mass_current = (data[f"litter_pool_{pool_name}"].to_numpy()) * cell_area + """Mass of the litter pool in carbon terms [kg C].""" + + self.c_n_ratio = data[f"c_n_ratio_{pool_name}"].to_numpy() + """Carbon nitrogen ratio of the litter pool [unitless].""" + + self.c_p_ratio = data[f"c_p_ratio_{pool_name}"].to_numpy() + """Carbon phosphorus ratio of the litter pool [unitless].""" + + def get_eaten( + self, consumed_mass: float, detritivore: Consumer, grid_cell_id: int + ) -> float: + """This function handles litter detritivory. + + Args: + consumed_mass: The mass intended to be consumed by the herbivore [kg]. + detritivore: The Consumer (AnimalCohort) consuming the Litter. + grid_cell_id: The cell id of the cell the animal cohort is in. + + Returns: + The actual mass consumed by the detritivore, adjusted for efficiencies [kg]. + """ + + # Check if the requested consumed mass is more than the available mass + actually_available_mass = min(self.mass_current[grid_cell_id], consumed_mass) + + # Calculate the mass of the consumed litter after mechanical efficiency + actual_consumed_mass = ( + actually_available_mass * detritivore.functional_group.mechanical_efficiency + ) + + # Update the litter pool mass to reflect the mass consumed + self.mass_current[grid_cell_id] -= actual_consumed_mass + + # Return the net mass gain of detritivory, after considering + # digestive efficiencies + net_mass_gain = ( + actual_consumed_mass * detritivore.functional_group.conversion_efficiency + ) - return self.decomposed_energy / (joules_per_kilo_carbon * grid_cell_area) + return net_mass_gain diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index bf745f18f..6b2256e01 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -88,7 +88,7 @@ def get_eaten( # Distribute the excreta energy across the excrement pools for excrement_pool in excrement_pools: - excrement_pool.decomposed_energy += excreta_energy_per_pool + excrement_pool.decomposed_carbon += excreta_energy_per_pool # Return the net mass gain of herbivory, considering both mechanical and # digestive efficiencies diff --git a/virtual_ecosystem/models/litter/carbon.py b/virtual_ecosystem/models/litter/carbon.py index 5fb59ff60..a968dbdbf 100644 --- a/virtual_ecosystem/models/litter/carbon.py +++ b/virtual_ecosystem/models/litter/carbon.py @@ -26,12 +26,57 @@ ) -def calculate_decay_rates( +def calculate_post_consumption_pools( above_metabolic: NDArray[np.float32], above_structural: NDArray[np.float32], woody: NDArray[np.float32], below_metabolic: NDArray[np.float32], below_structural: NDArray[np.float32], + consumption_above_metabolic: NDArray[np.float32], + consumption_above_structural: NDArray[np.float32], + consumption_woody: NDArray[np.float32], + consumption_below_metabolic: NDArray[np.float32], + consumption_below_structural: NDArray[np.float32], +) -> dict[str, NDArray[np.float32]]: + """Calculates the size of the five litter pools after animal consumption. + + At present the Virtual Ecosystem gives animals priority for consumption of litter. + And so only the litter not consumed by animals has a chance to decay. This is a + major assumption that we may have to revisit in future. + + Args: + above_metabolic: Above ground metabolic litter pool [kg C m^-2] + above_structural: Above ground structural litter pool [kg C m^-2] + woody: The woody litter pool [kg C m^-2] + below_metabolic: Below ground metabolic litter pool [kg C m^-2] + below_structural: Below ground structural litter pool [kg C m^-2] + consumption_above_metabolic: Amount of above-ground metabolic litter that has + been consumed by animals [kg C m^-2] + consumption_above_structural: Amount of above-ground structural litter that has + been consumed by animals [kg C m^-2] + consumption_woody: Amount of woody litter that has been consumed by animals [kg + C m^-2] + consumption_below_metabolic: Amount of below-ground metabolic litter that has + been consumed by animals [kg C m^-2] + consumption_below_structural: Amount of below-ground structural litter that has + been consumed by animals [kg C m^-2] + + Returns: + A dictionary containing the size of each litter pool after the mass consumed by + animals has been removed [kg C m^-2]. + """ + + return { + "above_metabolic": above_metabolic - consumption_above_metabolic, + "above_structural": above_structural - consumption_above_structural, + "woody": woody - consumption_woody, + "below_metabolic": below_metabolic - consumption_below_metabolic, + "below_structural": below_structural - consumption_below_structural, + } + + +def calculate_decay_rates( + post_consumption_pools: dict[str, NDArray[np.float32]], lignin_above_structural: NDArray[np.float32], lignin_woody: NDArray[np.float32], lignin_below_structural: NDArray[np.float32], @@ -44,11 +89,8 @@ def calculate_decay_rates( """Calculate the decay rate for all five of the litter pools. Args: - above_metabolic: Above ground metabolic litter pool [kg C m^-2] - above_structural: Above ground structural litter pool [kg C m^-2] - woody: The woody litter pool [kg C m^-2] - below_metabolic: Below ground metabolic litter pool [kg C m^-2] - below_structural: Below ground structural litter pool [kg C m^-2] + post_consumption_pools: The five litter pools after animal consumption has been + subtracted [kg C m^-2] lignin_above_structural: Proportion of above ground structural pool which is lignin [unitless] lignin_woody: Proportion of dead wood pool which is lignin [unitless] @@ -79,19 +121,19 @@ def calculate_decay_rates( # Calculate decay rate for each pool metabolic_above_decay = calculate_litter_decay_metabolic_above( temperature_factor=env_factors["temp_above"], - litter_pool_above_metabolic=above_metabolic, + litter_pool_above_metabolic=post_consumption_pools["above_metabolic"], litter_decay_coefficient=constants.litter_decay_constant_metabolic_above, ) structural_above_decay = calculate_litter_decay_structural_above( temperature_factor=env_factors["temp_above"], - litter_pool_above_structural=above_structural, + litter_pool_above_structural=post_consumption_pools["above_structural"], lignin_proportion=lignin_above_structural, litter_decay_coefficient=constants.litter_decay_constant_structural_above, lignin_inhibition_factor=constants.lignin_inhibition_factor, ) woody_decay = calculate_litter_decay_woody( temperature_factor=env_factors["temp_above"], - litter_pool_woody=woody, + litter_pool_woody=post_consumption_pools["woody"], lignin_proportion=lignin_woody, litter_decay_coefficient=constants.litter_decay_constant_woody, lignin_inhibition_factor=constants.lignin_inhibition_factor, @@ -99,13 +141,13 @@ def calculate_decay_rates( metabolic_below_decay = calculate_litter_decay_metabolic_below( temperature_factor=env_factors["temp_below"], moisture_factor=env_factors["water"], - litter_pool_below_metabolic=below_metabolic, + litter_pool_below_metabolic=post_consumption_pools["below_metabolic"], litter_decay_coefficient=constants.litter_decay_constant_metabolic_below, ) structural_below_decay = calculate_litter_decay_structural_below( temperature_factor=env_factors["temp_below"], moisture_factor=env_factors["water"], - litter_pool_below_structural=below_structural, + litter_pool_below_structural=post_consumption_pools["below_structural"], lignin_proportion=lignin_below_structural, litter_decay_coefficient=constants.litter_decay_constant_structural_below, lignin_inhibition_factor=constants.lignin_inhibition_factor, @@ -174,13 +216,7 @@ def calculate_total_C_mineralised( def calculate_updated_pools( - above_metabolic: NDArray[np.float32], - above_structural: NDArray[np.float32], - woody: NDArray[np.float32], - below_metabolic: NDArray[np.float32], - below_structural: NDArray[np.float32], - decomposed_excrement: NDArray[np.float32], - decomposed_carcasses: NDArray[np.float32], + post_consumption_pools: dict[str, NDArray[np.float32]], decay_rates: dict[str, NDArray[np.float32]], plant_inputs: dict[str, NDArray[np.float32]], update_interval: float, @@ -191,15 +227,8 @@ def calculate_updated_pools( each pool after the update interval, rather than a rate of change to be integrated. Args: - above_metabolic: Above ground metabolic litter pool [kg C m^-2] - above_structural: Above ground structural litter pool [kg C m^-2] - woody: The woody litter pool [kg C m^-2] - below_metabolic: Below ground metabolic litter pool [kg C m^-2] - below_structural: Below ground structural litter pool [kg C m^-2] - decomposed_excrement: Input rate of excrement from the animal model [kg C m^-2 - day^-1] - decomposed_carcasses: Input rate of (partially) decomposed carcass biomass from - the animal model [kg C m^-2 day^-1] + post_consumption_pools: The five litter pools after animal consumption has been + subtracted [kg C m^-2] decay_rates: Dictionary containing the rates of decay for all 5 litter pools [kg C m^-2 day^-1] plant_inputs: Dictionary containing the amount of each litter type that is added @@ -215,10 +244,8 @@ def calculate_updated_pools( # Net pool changes are found by combining input and decay rates, and then # multiplying by the update time step. - change_in_metabolic_above = ( - plant_inputs["above_ground_metabolic"] - + (decomposed_excrement + decomposed_carcasses - decay_rates["metabolic_above"]) - * update_interval + change_in_metabolic_above = plant_inputs["above_ground_metabolic"] - ( + decay_rates["metabolic_above"] * update_interval ) change_in_structural_above = plant_inputs["above_ground_structural"] - ( decay_rates["structural_above"] * update_interval @@ -233,11 +260,15 @@ def calculate_updated_pools( # New value for each pool is found and returned in a dictionary return { - "above_metabolic": above_metabolic + change_in_metabolic_above, - "above_structural": above_structural + change_in_structural_above, - "woody": woody + change_in_woody, - "below_metabolic": below_metabolic + change_in_metabolic_below, - "below_structural": below_structural + change_in_structural_below, + "above_metabolic": post_consumption_pools["above_metabolic"] + + change_in_metabolic_above, + "above_structural": post_consumption_pools["above_structural"] + + change_in_structural_above, + "woody": post_consumption_pools["woody"] + change_in_woody, + "below_metabolic": post_consumption_pools["below_metabolic"] + + change_in_metabolic_below, + "below_structural": post_consumption_pools["below_structural"] + + change_in_structural_below, } diff --git a/virtual_ecosystem/models/litter/input_partition.py b/virtual_ecosystem/models/litter/input_partition.py index 48f186892..612125db9 100644 --- a/virtual_ecosystem/models/litter/input_partition.py +++ b/virtual_ecosystem/models/litter/input_partition.py @@ -1,5 +1,5 @@ """The ``models.litter.input_partition`` module handles the partitioning of dead plant -and animal matter into the various pools of the litter model. +matter into the various pools of the litter model. """ # noqa: D205 import numpy as np diff --git a/virtual_ecosystem/models/litter/litter_model.py b/virtual_ecosystem/models/litter/litter_model.py index 20ce65b10..e26297c62 100644 --- a/virtual_ecosystem/models/litter/litter_model.py +++ b/virtual_ecosystem/models/litter/litter_model.py @@ -16,12 +16,8 @@ class instance. If errors crop here when converting the information from the con be reported as one. """ # noqa: D205 -# TODO - At the moment this model only receives two things from the animal model, -# excrement and decayed carcass biomass. Both of these are simply added to the above -# ground metabolic litter. In future, bones and feathers should also be added, these -# will be handled using the more recalcitrant litter pools. However, we are leaving off -# adding these for now as they have minimal effects on the carbon cycle, though they -# probably matter for other nutrient cycles. +# TODO - At the moment this model only receives nothing from the animal model. In +# future, litter flows due to waste from herbivory need to be added. # FUTURE - Potentially make a more numerically accurate version of this model by using # differential equations at some point. In reality, litter chemistry should change @@ -47,6 +43,7 @@ class instance. If errors crop here when converting the information from the con from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.litter.carbon import ( calculate_decay_rates, + calculate_post_consumption_pools, calculate_total_C_mineralised, calculate_updated_pools, ) @@ -114,6 +111,11 @@ class LitterModel( "leaf_turnover_c_n_ratio", "plant_reproductive_tissue_turnover_c_n_ratio", "root_turnover_c_n_ratio", + "litter_consumption_above_metabolic", + "litter_consumption_above_structural", + "litter_consumption_woody", + "litter_consumption_below_metabolic", + "litter_consumption_below_structural", ), vars_updated=( "litter_pool_above_metabolic", @@ -283,13 +285,32 @@ def update(self, time_index: int, **kwargs: Any) -> None: **kwargs: Further arguments to the update method. """ - # Calculate the litter pool decay rates - decay_rates = calculate_decay_rates( + # Calculate the pool sizes after animal consumption has occurred, which then get + # used then for subsequent calculations + consumed_pools = calculate_post_consumption_pools( above_metabolic=self.data["litter_pool_above_metabolic"].to_numpy(), above_structural=self.data["litter_pool_above_structural"].to_numpy(), woody=self.data["litter_pool_woody"].to_numpy(), below_metabolic=self.data["litter_pool_below_metabolic"].to_numpy(), below_structural=self.data["litter_pool_below_structural"].to_numpy(), + consumption_above_metabolic=self.data[ + "litter_consumption_above_metabolic" + ].to_numpy(), + consumption_above_structural=self.data[ + "litter_consumption_above_structural" + ].to_numpy(), + consumption_woody=self.data["litter_consumption_woody"].to_numpy(), + consumption_below_metabolic=self.data[ + "litter_consumption_below_metabolic" + ].to_numpy(), + consumption_below_structural=self.data[ + "litter_consumption_below_structural" + ].to_numpy(), + ) + + # Calculate the litter pool decay rates + decay_rates = calculate_decay_rates( + post_consumption_pools=consumed_pools, lignin_above_structural=self.data["lignin_above_structural"].to_numpy(), lignin_woody=self.data["lignin_woody"].to_numpy(), lignin_below_structural=self.data["lignin_below_structural"].to_numpy(), @@ -336,13 +357,7 @@ def update(self, time_index: int, **kwargs: Any) -> None: # Calculate the updated pool masses updated_pools = calculate_updated_pools( - above_metabolic=self.data["litter_pool_above_metabolic"].to_numpy(), - above_structural=self.data["litter_pool_above_structural"].to_numpy(), - woody=self.data["litter_pool_woody"].to_numpy(), - below_metabolic=self.data["litter_pool_below_metabolic"].to_numpy(), - below_structural=self.data["litter_pool_below_structural"].to_numpy(), - decomposed_excrement=self.data["decomposed_excrement"].to_numpy(), - decomposed_carcasses=self.data["decomposed_carcasses"].to_numpy(), + post_consumption_pools=consumed_pools, decay_rates=decay_rates, plant_inputs=plant_inputs, update_interval=self.model_timing.update_interval_quantity.to( From 8e5227fd702d782d135cc432d27fb024e3ee491d Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 4 Oct 2024 16:36:35 +0100 Subject: [PATCH 49/62] Updated animal tests for new decay functionality. --- tests/models/animals/conftest.py | 101 ++++++++- tests/models/animals/test_animal_cohorts.py | 167 +++++++++------ tests/models/animals/test_animal_model.py | 202 +++++++++++++----- tests/models/animals/test_decay.py | 138 ++++++++++-- tests/models/animals/test_plant_resources.py | 17 +- .../models/animal/animal_cohorts.py | 8 +- .../models/animal/plant_resources.py | 63 +++--- virtual_ecosystem/models/animal/protocols.py | 5 +- 8 files changed, 505 insertions(+), 196 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 86a2c68b5..8c806fcc4 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -424,6 +424,27 @@ def dummy_animal_data(animal_fixture_core_components): dims=("groundwater_layers", "cell_id"), ) + # Adding in litter variables as these are also needed now + litter_pools = DataArray(np.full(data.grid.n_cells, fill_value=1.5), dims="cell_id") + litter_ratios = DataArray( + np.full(data.grid.n_cells, fill_value=25.5), dims="cell_id" + ) + data["litter_pool_above_metabolic"] = litter_pools + data["litter_pool_above_structural"] = litter_pools + data["litter_pool_woody"] = litter_pools + data["litter_pool_below_metabolic"] = litter_pools + data["litter_pool_below_structural"] = litter_pools + data["c_n_ratio_above_metabolic"] = litter_ratios + data["c_n_ratio_above_structural"] = litter_ratios + data["c_n_ratio_woody"] = litter_ratios + data["c_n_ratio_below_metabolic"] = litter_ratios + data["c_n_ratio_below_structural"] = litter_ratios + data["c_p_ratio_above_metabolic"] = litter_ratios + data["c_p_ratio_above_structural"] = litter_ratios + data["c_p_ratio_woody"] = litter_ratios + data["c_p_ratio_below_metabolic"] = litter_ratios + data["c_p_ratio_below_structural"] = litter_ratios + # Initialize total_animal_respiration with zeros for each cell total_animal_respiration = np.zeros( len(animal_fixture_core_components.grid.cell_id) @@ -664,7 +685,14 @@ def excrement_pool_instance(): """Fixture for a soil pool used in tests.""" from virtual_ecosystem.models.animal.decay import ExcrementPool - return ExcrementPool(100000.0, 0.0) + return ExcrementPool( + scavengeable_carbon=0.0, + decomposed_carbon=0.0, + scavengeable_nitrogen=0.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=0.0, + decomposed_phosphorus=0.0, + ) @pytest.fixture @@ -715,10 +743,17 @@ def animal_list_instance( @pytest.fixture def carcass_pool_instance(): - """Fixture for an carcass pool used in tests.""" + """Fixture for a carcass pool used in tests.""" from virtual_ecosystem.models.animal.decay import CarcassPool - return CarcassPool(0.0, 0.0) + return CarcassPool( + scavengeable_carbon=0.0, + decomposed_carbon=0.0, + scavengeable_nitrogen=0.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=0.0, + decomposed_phosphorus=0.0, + ) @pytest.fixture @@ -726,4 +761,62 @@ def carcass_pools_instance(): """Fixture for carcass pools used in tests.""" from virtual_ecosystem.models.animal.decay import CarcassPool - return {1: [CarcassPool(scavengeable_energy=500.0, decomposed_energy=0.0)]} + return { + 1: [ + CarcassPool( + scavengeable_carbon=500.0, + decomposed_carbon=0.0, + scavengeable_nitrogen=100.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=50.0, + decomposed_phosphorus=0.0, + ) + ] + } + + +@pytest.fixture +def litter_data_instance(fixture_core_components): + """Creates a dummy litter data for use in tests.""" + + from virtual_ecosystem.core.data import Data + + # Setup the data object with four cells. + data = Data(fixture_core_components.grid) + + # The required data is now added. This is basically the 5 litter pool sizes and + # stoichiometric ratios + data_values = { + "litter_pool_above_metabolic": [0.3, 0.15, 0.07, 0.07], + "litter_pool_above_structural": [0.5, 0.25, 0.09, 0.09], + "litter_pool_woody": [4.7, 11.8, 7.3, 7.3], + "litter_pool_below_metabolic": [0.4, 0.37, 0.07, 0.07], + "litter_pool_below_structural": [0.6, 0.31, 0.02, 0.02], + "c_n_ratio_above_metabolic": [7.3, 8.7, 10.1, 9.8], + "c_n_ratio_above_structural": [37.5, 43.2, 45.8, 50.2], + "c_n_ratio_woody": [55.5, 63.3, 47.3, 59.1], + "c_n_ratio_below_metabolic": [10.7, 11.3, 15.2, 12.4], + "c_n_ratio_below_structural": [50.5, 55.6, 73.1, 61.2], + "c_p_ratio_above_metabolic": [57.3, 68.7, 100.1, 95.8], + "c_p_ratio_above_structural": [337.5, 473.2, 415.8, 570.2], + "c_p_ratio_woody": [555.5, 763.3, 847.3, 599.1], + "c_p_ratio_below_metabolic": [310.7, 411.3, 315.2, 412.4], + "c_p_ratio_below_structural": [550.5, 595.6, 773.1, 651.2], + } + + for var_name, var_values in data_values.items(): + data[var_name] = DataArray(var_values, dims=["cell_id"]) + + return data + + +@pytest.fixture +def litter_pool_instance(litter_data_instance): + """Fixture for a litter pool class to be used in tests.""" + from virtual_ecosystem.models.animal.decay import LitterPool + + return LitterPool( + pool_name="above_metabolic", + data=litter_data_instance, + cell_area=10000, + ) diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index dd9d89b46..5258ad235 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -229,8 +229,8 @@ def test_metabolize( assert isclose(cohort_instance.mass_current, expected_final_mass, rtol=1e-9) @pytest.mark.parametrize( - "cohort_type, excreta_mass, initial_pool_energy, num_pools," - "expected_pool_energy", + "cohort_type, excreta_mass, initial_pool_carbon, num_pools," + "expected_pool_carbon", [ ( "herbivore", @@ -307,9 +307,9 @@ def test_excrete( ectotherm_cohort_instance, cohort_type, excreta_mass, - initial_pool_energy, + initial_pool_carbon, num_pools, - expected_pool_energy, + expected_pool_carbon, ): """Testing excrete method for various scenarios.""" @@ -325,34 +325,34 @@ def test_excrete( excrement_pools = [] for _ in range(num_pools): excrement_pool = mocker.Mock() - excrement_pool.decomposed_energy = initial_pool_energy - excrement_pool.scavengeable_energy = initial_pool_energy + excrement_pool.decomposed_carbon = initial_pool_carbon + excrement_pool.scavengeable_carbon = initial_pool_carbon excrement_pools.append(excrement_pool) # Call the excrete method cohort_instance.excrete(excreta_mass, excrement_pools) - # Check the expected results + # Check the expected results for carbon pools for excrement_pool in excrement_pools: - expected_decomposed_energy = ( - initial_pool_energy - + cohort_instance.decay_fraction_excrement - * excreta_mass - / num_pools - * cohort_instance.constants.nitrogen_excreta_proportion + excreta_mass_per_community = ( + excreta_mass / num_pools + ) * cohort_instance.constants.nitrogen_excreta_proportion + + expected_decomposed_carbon = ( + initial_pool_carbon + + cohort_instance.decay_fraction_excrement * excreta_mass_per_community ) - expected_scavengeable_energy = ( - initial_pool_energy + expected_scavengeable_carbon = ( + initial_pool_carbon + (1 - cohort_instance.decay_fraction_excrement) - * excreta_mass - / num_pools - * cohort_instance.constants.nitrogen_excreta_proportion + * excreta_mass_per_community ) - assert excrement_pool.decomposed_energy == pytest.approx( - expected_decomposed_energy + + assert excrement_pool.decomposed_carbon == pytest.approx( + expected_decomposed_carbon ) - assert excrement_pool.scavengeable_energy == pytest.approx( - expected_scavengeable_energy + assert excrement_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon ) @pytest.mark.parametrize( @@ -399,15 +399,19 @@ def test_respire( assert carbon_waste == expected_carbon_waste @pytest.mark.parametrize( - "scav_initial, scav_final, decomp_initial, decomp_final, consumed_energy," - "num_pools", + "scav_initial, decomp_initial, consumed_mass, num_pools", [ - (1000.0, 1500.0, 0.0, 500.0, 1000.0, 1), - (0.0, 500.0, 1000.0, 1500.0, 1000.0, 1), - (1000.0, 1000.0, 0.0, 0.0, 0.0, 1), - (0.0, 0.0, 1000.0, 1000.0, 0.0, 1), - (1000.0, 1166.67, 0.0, 166.67, 1000.0, 3), # Test with multiple pools - (0.0, 166.67, 1000.0, 1166.67, 1000.0, 3), # Test with multiple pools + (1000.0, 0.0, 1000.0, 1), # Single pool, waste mass consumed + (0.0, 1000.0, 1000.0, 1), # Single pool, initial decomposed + (1000.0, 0.0, 0.0, 1), # No mass consumed, single pool + (0.0, 1000.0, 0.0, 1), # No mass consumed, initial decomposed + (1000.0, 0.0, 1000.0, 3), # Test with multiple pools + ( + 0.0, + 1000.0, + 1000.0, + 3, + ), # Test with multiple pools, initial decomposed ], ids=[ "single_pool_scenario_1", @@ -423,48 +427,47 @@ def test_defecate( mocker, herbivore_cohort_instance, scav_initial, - scav_final, decomp_initial, - decomp_final, - consumed_energy, + consumed_mass, num_pools, ): - """Testing defecate() for varying soil energy levels and multiple pools.""" + """Testing defecate() for varying carbon mass levels and multiple pools.""" # Mock the excrement pools excrement_pools = [] for _ in range(num_pools): excrement_pool = mocker.Mock() - excrement_pool.scavengeable_energy = scav_initial - excrement_pool.decomposed_energy = decomp_initial + excrement_pool.scavengeable_carbon = scav_initial + excrement_pool.decomposed_carbon = decomp_initial excrement_pools.append(excrement_pool) # Call the defecate method - herbivore_cohort_instance.defecate(excrement_pools, consumed_energy) + herbivore_cohort_instance.defecate(excrement_pools, consumed_mass) # Check the expected results for excrement_pool in excrement_pools: - expected_scavengeable_energy = ( + expected_scavengeable_carbon = ( scav_initial + (1 - herbivore_cohort_instance.decay_fraction_excrement) - * consumed_energy + * consumed_mass / num_pools * herbivore_cohort_instance.functional_group.conversion_efficiency * herbivore_cohort_instance.individuals ) - expected_decomposed_energy = ( + expected_decomposed_carbon = ( decomp_initial + herbivore_cohort_instance.decay_fraction_excrement - * consumed_energy + * consumed_mass / num_pools * herbivore_cohort_instance.functional_group.conversion_efficiency * herbivore_cohort_instance.individuals ) - assert excrement_pool.scavengeable_energy == pytest.approx( - expected_scavengeable_energy + + assert excrement_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon ) - assert excrement_pool.decomposed_energy == pytest.approx( - expected_decomposed_energy + assert excrement_pool.decomposed_carbon == pytest.approx( + expected_decomposed_carbon ) @pytest.mark.parametrize( @@ -487,18 +490,26 @@ def test_increase_age(self, herbivore_cohort_instance, dt, initial_age, final_ag "number_dead", "initial_pop", "final_pop", - "initial_carcass", - "final_carcass", + "initial_scavengeable_carbon", + "final_scavengeable_carbon", "decomp_carcass", "num_pools", ], argvalues=[ - (0, 0, 0, 0.0, 0.0, 0.0, 1), - (0, 1000, 1000, 0.0, 0.0, 0.0, 1), - (1, 1, 0, 1.0, 8001.0, 2001.0, 1), - (100, 200, 100, 0.0, 800000.0, 200000.0, 1), - (1, 1, 0, 1.0, 8001.0, 667.6666666, 3), - (100, 200, 100, 0.0, 800000.0, 66666.66666666, 3), + (0, 0, 0, 0.0, 0.0, 0.0, 1), # No deaths, empty population + (0, 1000, 1000, 0.0, 0.0, 0.0, 1), # No deaths, non-empty population + (1, 1, 0, 1.0, 8001.0, 2000.0, 1), # Single death, single pool + (100, 200, 100, 0.0, 800000.0, 200000.0, 1), # Multiple deaths, single pool + (1, 1, 0, 1.0, 2667.6667, 666.67, 3), # Single death, multiple pools + ( + 100, + 200, + 100, + 0.0, + 266666.67, + 66666.67, + 3, + ), # Multiple deaths, multiple pools ], ids=[ "zero_death_empty_pop", @@ -511,43 +522,57 @@ def test_increase_age(self, herbivore_cohort_instance, dt, initial_age, final_ag ) def test_die_individual( self, - mocker, herbivore_cohort_instance, + carcass_pools_instance, number_dead, initial_pop, final_pop, - initial_carcass, - final_carcass, + initial_scavengeable_carbon, + final_scavengeable_carbon, decomp_carcass, num_pools, ): - """Testing death.""" + """Testing death and carcass mass transfer to pools.""" + + from virtual_ecosystem.models.animal.decay import CarcassPool + # Set the initial population for the herbivore cohort herbivore_cohort_instance.individuals = initial_pop - # Mock the carcass pools - carcass_pools = [] - for _ in range(num_pools): - carcass_pool = mocker.Mock() - carcass_pool.scavengeable_energy = initial_carcass - carcass_pool.decomposed_energy = initial_carcass - carcass_pools.append(carcass_pool) + # Use the `carcass_pools_instance` fixture + carcass_pools = { + key: [ + CarcassPool( + scavengeable_carbon=initial_scavengeable_carbon, + decomposed_carbon=0.0, + scavengeable_nitrogen=0.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=0.0, + decomposed_phosphorus=0.0, + ) + for _ in range(num_pools) + ] + for key in carcass_pools_instance.keys() + } - herbivore_cohort_instance.die_individual(number_dead, carcass_pools) + # Call the die_individual method + herbivore_cohort_instance.die_individual(number_dead, carcass_pools[1]) + # Check the population after death assert herbivore_cohort_instance.individuals == final_pop - for carcass_pool in carcass_pools: - expected_scavengeable_energy = ( - initial_carcass + + # Check the expected results for carcass mass distribution + for carcass_pool in carcass_pools[1]: + expected_scavengeable_carbon = ( + initial_scavengeable_carbon + (1 - herbivore_cohort_instance.decay_fraction_carcasses) * (number_dead * herbivore_cohort_instance.mass_current) / num_pools ) - assert carcass_pool.scavengeable_energy == pytest.approx( - expected_scavengeable_energy + assert carcass_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon ) - assert carcass_pool.decomposed_energy == pytest.approx(decomp_carcass) @pytest.mark.parametrize( "initial_individuals, potential_consumed_mass, mechanical_efficiency," diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index f51ae1750..60b5eece3 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -72,8 +72,29 @@ def test_animal_model_initialization( ), (INFO, "Replacing data array for 'total_animal_respiration'"), (INFO, "Adding data array for 'population_densities'"), - (INFO, "Adding data array for 'decomposed_excrement'"), - (INFO, "Adding data array for 'decomposed_carcasses'"), + (INFO, "Adding data array for 'decomposed_excrement_carbon'"), + (INFO, "Adding data array for 'decomposed_excrement_nitrogen'"), + (INFO, "Adding data array for 'decomposed_excrement_phosphorus'"), + (INFO, "Adding data array for 'decomposed_carcasses_carbon'"), + (INFO, "Adding data array for 'decomposed_carcasses_nitrogen'"), + (INFO, "Adding data array for 'decomposed_carcasses_phosphorus'"), + ( + INFO, + "Adding data array for 'litter_consumption_above_metabolic'", + ), + ( + INFO, + "Adding data array for 'litter_consumption_above_structural'", + ), + (INFO, "Adding data array for 'litter_consumption_woody'"), + ( + INFO, + "Adding data array for 'litter_consumption_below_metabolic'", + ), + ( + INFO, + "Adding data array for 'litter_consumption_below_structural'", + ), ), id="success", ), @@ -176,63 +197,6 @@ def test_update_method_time_index_argument( assert True - def test_calculate_litter_additions( - self, functional_group_list_instance, animal_data_for_model_instance - ): - """Test that litter additions from animal model are calculated correctly.""" - - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.models.animal.animal_model import AnimalModel - - # Build the config object and core components - config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') - core_components = CoreComponents(config) - - # Use it to initialize the model - model = AnimalModel( - data=animal_data_for_model_instance, - core_components=core_components, - functional_groups=functional_group_list_instance, - ) - - # Update the waste pools - decomposed_excrement = [3.5e3, 5.6e4, 5.9e4, 2.3e6, 0, 0, 0, 0, 0] - for energy, excrement_pools in zip( - decomposed_excrement, model.excrement_pools.values() - ): - for excrement_pool in excrement_pools: - excrement_pool.decomposed_energy = energy - - decomposed_carcasses = [7.5e6, 3.4e7, 8.1e7, 1.7e8, 0, 0, 0, 0, 0] - for energy, carcass_pools in zip( - decomposed_carcasses, model.carcass_pools.values() - ): - for carcass_pool in carcass_pools: - carcass_pool.decomposed_energy = energy - - # Calculate litter additions - litter_additions = model.calculate_litter_additions() - - # Check that litter addition pools are as expected - assert np.allclose( - litter_additions["decomposed_excrement"], - [5e-08, 8e-07, 8.42857e-07, 3.28571e-05, 0, 0, 0, 0, 0], - ) - assert np.allclose( - litter_additions["decomposed_carcasses"], - [1.0714e-4, 4.8571e-4, 1.15714e-3, 2.42857e-3, 0, 0, 0, 0, 0], - ) - - # Check that the function has reset the pools correctly - for excrement_pools in model.excrement_pools.values(): - assert np.allclose( - [pool.decomposed_energy for pool in excrement_pools], 0.0 - ) - - for carcass_pools in model.carcass_pools.values(): - assert np.allclose([pool.decomposed_energy for pool in carcass_pools], 0.0) - def test_setup_initializes_total_animal_respiration( self, prepared_animal_model_instance, @@ -359,6 +323,126 @@ def test_update_population_densities(self, prepared_animal_model_instance): f"Expected: {expected_density}, Found: {calculated_density}" ) + def test_populate_litter_pools( + self, + litter_data_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test function to populate animal consumable litter pool works properly.""" + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + model = AnimalModel( + data=litter_data_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) + + litter_pools = model.populate_litter_pools() + # Check that all five pools have been populated, with the correct values + pool_names = [ + "above_metabolic", + "above_structural", + "woody", + "below_metabolic", + "below_structural", + ] + for pool_name in pool_names: + assert np.allclose( + litter_pools[pool_name].mass_current, + litter_data_instance[f"litter_pool_{pool_name}"] + * fixture_core_components.grid.cell_area, + ) + assert np.allclose( + litter_pools[pool_name].c_n_ratio, + litter_data_instance[f"c_n_ratio_{pool_name}"], + ) + assert np.allclose( + litter_pools[pool_name].c_p_ratio, + litter_data_instance[f"c_p_ratio_{pool_name}"], + ) + + def test_calculate_total_litter_consumption( + self, + litter_data_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test calculation of total consumption of litter by animals is correct.""" + from copy import deepcopy + + from virtual_ecosystem.models.animal.animal_model import AnimalModel + from virtual_ecosystem.models.animal.decay import LitterPool + + model = AnimalModel( + data=litter_data_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) + + new_data = deepcopy(litter_data_instance) + # Add new values for each pool + new_data["litter_pool_above_metabolic"] = ( + litter_data_instance["litter_pool_above_metabolic"] - 0.03 + ) + new_data["litter_pool_above_structural"] = ( + litter_data_instance["litter_pool_above_structural"] - 0.04 + ) + new_data["litter_pool_woody"] = litter_data_instance["litter_pool_woody"] - 1.2 + new_data["litter_pool_below_metabolic"] = ( + litter_data_instance["litter_pool_below_metabolic"] - 0.06 + ) + new_data["litter_pool_below_structural"] = ( + litter_data_instance["litter_pool_below_structural"] - 0.01 + ) + + # Make an updated set of litter pools + pool_names = [ + "above_metabolic", + "above_structural", + "woody", + "below_metabolic", + "below_structural", + ] + new_litter_pools = { + pool_name: LitterPool( + pool_name=pool_name, + data=new_data, + cell_area=fixture_core_components.grid.cell_area, + ) + for pool_name in pool_names + } + + # Calculate litter consumption + consumption = model.calculate_total_litter_consumption( + litter_pools=new_litter_pools + ) + + assert np.allclose( + consumption["litter_consumption_above_metabolic"], + 0.03 * np.ones(4), + ) + assert np.allclose( + consumption["litter_consumption_above_structural"], + 0.04 * np.ones(4), + ) + assert np.allclose( + consumption["litter_consumption_woody"], + 1.2 * np.ones(4), + ) + assert np.allclose( + consumption["litter_consumption_below_metabolic"], + 0.06 * np.ones(4), + ) + assert np.allclose( + consumption["litter_consumption_below_structural"], + 0.01 * np.ones(4), + ) + def test_calculate_density_for_cohort(self, prepared_animal_model_instance, mocker): """Test the calculate_density_for_cohort method.""" diff --git a/tests/models/animals/test_decay.py b/tests/models/animals/test_decay.py index c77bd6417..94dcf204f 100644 --- a/tests/models/animals/test_decay.py +++ b/tests/models/animals/test_decay.py @@ -13,13 +13,34 @@ def test_initialization(self): """Testing initialization of CarcassPool.""" from virtual_ecosystem.models.animal.decay import CarcassPool - carcasses = CarcassPool(1000.7, 25.0) - assert pytest.approx(carcasses.scavengeable_energy) == 1000.7 - assert pytest.approx(carcasses.decomposed_energy) == 25.0 - assert pytest.approx(carcasses.decomposed_carbon(1.0)) == 2.5e-5 - assert pytest.approx(carcasses.decomposed_carbon(10.0)) == 2.5e-6 - assert pytest.approx(carcasses.decomposed_carbon(25.0)) == 1.0e-6 - assert pytest.approx(carcasses.decomposed_carbon(5000.0)) == 5.0e-9 + carcasses = CarcassPool( + scavengeable_carbon=1.0007e-2, + decomposed_carbon=2.5e-5, + scavengeable_nitrogen=0.000133333332, + decomposed_nitrogen=3.3333333e-6, + scavengeable_phosphorus=1.33333332e-6, + decomposed_phosphorus=3.3333333e-8, + ) + assert pytest.approx(carcasses.scavengeable_carbon) == 1.0007e-2 + assert pytest.approx(carcasses.decomposed_carbon) == 2.5e-5 + assert pytest.approx(carcasses.scavengeable_nitrogen) == 0.000133333332 + assert pytest.approx(carcasses.decomposed_nitrogen) == 3.3333333e-6 + assert pytest.approx(carcasses.scavengeable_phosphorus) == 1.33333332e-6 + assert pytest.approx(carcasses.decomposed_phosphorus) == 3.3333333e-8 + assert ( + pytest.approx(carcasses.decomposed_nutrient_per_area("carbon", 10000)) + == 2.5e-9 + ) + assert ( + pytest.approx(carcasses.decomposed_nutrient_per_area("nitrogen", 10000)) + == 3.3333333e-10 + ) + assert ( + pytest.approx(carcasses.decomposed_nutrient_per_area("phosphorus", 10000)) + == 3.3333333e-12 + ) + with pytest.raises(AttributeError): + carcasses.decomposed_nutrient_per_area("molybdenum", 10000) class TestExcrementPool: @@ -29,11 +50,100 @@ def test_initialization(self): """Testing initialization of CarcassPool.""" from virtual_ecosystem.models.animal.decay import ExcrementPool - poo = ExcrementPool(77.7, 25.0) + poo = ExcrementPool( + scavengeable_carbon=7.77e-5, + decomposed_carbon=2.5e-5, + scavengeable_nitrogen=1e-5, + decomposed_nitrogen=3.3333333e-6, + scavengeable_phosphorus=1e-7, + decomposed_phosphorus=3.3333333e-8, + ) # Test that function to calculate stored carbon works as expected - assert pytest.approx(poo.scavengeable_energy) == 77.7 - assert pytest.approx(poo.decomposed_energy) == 25.0 - assert pytest.approx(poo.decomposed_carbon(1.0)) == 2.5e-5 - assert pytest.approx(poo.decomposed_carbon(10.0)) == 2.5e-6 - assert pytest.approx(poo.decomposed_carbon(25.0)) == 1.0e-6 - assert pytest.approx(poo.decomposed_carbon(5000.0)) == 5.0e-9 + assert pytest.approx(poo.scavengeable_carbon) == 7.77e-5 + assert pytest.approx(poo.decomposed_carbon) == 2.5e-5 + assert pytest.approx(poo.scavengeable_nitrogen) == 1e-5 + assert pytest.approx(poo.decomposed_nitrogen) == 3.3333333e-6 + assert pytest.approx(poo.scavengeable_phosphorus) == 1e-7 + assert pytest.approx(poo.decomposed_phosphorus) == 3.3333333e-8 + assert ( + pytest.approx(poo.decomposed_nutrient_per_area("carbon", 10000)) == 2.5e-9 + ) + assert ( + pytest.approx(poo.decomposed_nutrient_per_area("nitrogen", 10000)) + == 3.3333333e-10 + ) + assert ( + pytest.approx(poo.decomposed_nutrient_per_area("phosphorus", 10000)) + == 3.3333333e-12 + ) + with pytest.raises(AttributeError): + poo.decomposed_nutrient_per_area("molybdenum", 10000) + + +@pytest.mark.parametrize( + argnames=[ + "decay_rate", + "scavenging_rate", + "expected_split", + ], + argvalues=[ + (0.25, 0.25, 0.5), + (0.0625, 0.25, 0.2), + (0.25, 0.0625, 0.8), + ], +) +def test_find_decay_consumed_split(decay_rate, scavenging_rate, expected_split): + """Test the function to find decay/scavenged split works as expected.""" + from virtual_ecosystem.models.animal.decay import find_decay_consumed_split + + actual_split = find_decay_consumed_split( + microbial_decay_rate=decay_rate, animal_scavenging_rate=scavenging_rate + ) + + assert actual_split == expected_split + + +class TestLitterPool: + """Test LitterPool class.""" + + def test_get_eaten(self, litter_pool_instance, herbivore_cohort_instance): + """Test the get_eaten method for LitterPool.""" + import pytest + + consumed_mass = 50.0 # Define a mass to be consumed for the test + cell_id = 3 + initial_mass_current = litter_pool_instance.mass_current[cell_id] + initial_c_n_ratio = litter_pool_instance.c_n_ratio[cell_id] + initial_c_p_ratio = litter_pool_instance.c_p_ratio[cell_id] + + actual_mass_gain = litter_pool_instance.get_eaten( + consumed_mass, herbivore_cohort_instance, grid_cell_id=cell_id + ) + + # Check if the plant mass has been correctly reduced + assert litter_pool_instance.mass_current[cell_id] == pytest.approx( + initial_mass_current + - ( + consumed_mass + * herbivore_cohort_instance.functional_group.mechanical_efficiency + ) + ), "Litter mass should be reduced by the consumed amount." + + # Check if the actual mass gain matches the expected value after + # efficiency adjustments + expected_mass_gain = ( + consumed_mass + * herbivore_cohort_instance.functional_group.mechanical_efficiency + * herbivore_cohort_instance.functional_group.conversion_efficiency + ) + assert actual_mass_gain == pytest.approx( + expected_mass_gain + ), "Actual mass gain should match expected value after efficiency adjustments." + + # Check that carbon:nitrogen and carbon:phosphorus ratios remain unchanged + assert initial_c_n_ratio == pytest.approx( + litter_pool_instance.c_n_ratio[cell_id] + ) + assert initial_c_p_ratio == pytest.approx( + litter_pool_instance.c_p_ratio[cell_id] + ) diff --git a/tests/models/animals/test_plant_resources.py b/tests/models/animals/test_plant_resources.py index 329f58872..2f6ab6c67 100644 --- a/tests/models/animals/test_plant_resources.py +++ b/tests/models/animals/test_plant_resources.py @@ -12,8 +12,9 @@ def test_get_eaten( consumed_mass = 50.0 # Define a mass to be consumed for the test initial_mass_current = plant_instance.mass_current - initial_excrement_energy = excrement_pool_instance.decomposed_energy + initial_excrement_carbon = excrement_pool_instance.decomposed_carbon + # Call the method actual_mass_gain = plant_instance.get_eaten( consumed_mass, herbivore_cohort_instance, [excrement_pool_instance] ) @@ -23,8 +24,8 @@ def test_get_eaten( initial_mass_current - consumed_mass ), "Plant mass should be reduced by the consumed amount." - # Check if the actual mass gain matches the expected value after - # efficiency adjustments + # Check if the actual mass gain matches the expected value after efficiency + # adjustments expected_mass_gain = ( consumed_mass * herbivore_cohort_instance.functional_group.mechanical_efficiency @@ -38,9 +39,9 @@ def test_get_eaten( excess_mass = consumed_mass * ( 1 - herbivore_cohort_instance.functional_group.mechanical_efficiency ) - expected_excrement_energy_increase = ( - excess_mass * plant_instance.constants.energy_density["plant"] + expected_excrement_carbon_increase = excess_mass / len( + [excrement_pool_instance] ) - assert excrement_pool_instance.decomposed_energy == pytest.approx( - initial_excrement_energy + expected_excrement_energy_increase - ), "Excrement pool energy should increase by energy value of the excess mass." + assert excrement_pool_instance.decomposed_carbon == pytest.approx( + initial_excrement_carbon + expected_excrement_carbon_increase + ), "Excrement pool carbon should increase by the mass of the excess carbon." diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index fa93676ff..01cd7f92a 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -259,21 +259,21 @@ def defecate( # Find total waste mass, the total amount of waste is found by the # average cohort member * number individuals. - waste_energy = ( + waste_mass = ( mass_consumed * self.functional_group.conversion_efficiency * self.individuals ) - waste_energy_per_community = waste_energy / number_communities + waste_mass_per_community = waste_mass / number_communities for excrement_pool in excrement_pools: # This total waste is then split between decay and scavengeable excrement excrement_pool.scavengeable_carbon += ( 1 - self.decay_fraction_excrement - ) * waste_energy_per_community + ) * waste_mass_per_community excrement_pool.decomposed_carbon += ( - self.decay_fraction_excrement * waste_energy_per_community + self.decay_fraction_excrement * waste_mass_per_community ) def increase_age(self, dt: timedelta64) -> None: diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 6b2256e01..5c7441836 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -8,8 +8,9 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import ExcrementPool -from virtual_ecosystem.models.animal.protocols import Consumer + +# from virtual_ecosystem.models.animal.decay import ExcrementPool +from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool class PlantResources: @@ -22,13 +23,12 @@ class PlantResources: At present, it only exposes a single resource - the total leaf mass of the entire plant community in a cell - but this is likely to expand to allow vertical structure of plant resources, diversification to fruit and other resources and probably plant - cohort specific herbivory. - - TODO: fix mass_current after resolving example data questions + cohort-specific herbivory. Args: data: A Data object containing information from the plants model. cell_id: The cell id for the plant community to expose. + constants: Animal-related constants, including plant energy density. """ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: @@ -37,61 +37,56 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: """A reference to the core data object.""" self.cell_id = cell_id """The community cell containing the plant resources.""" - # self.mass_current: float = ( - # data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() - # ) self.mass_current = 100000.0 """The mass of the plant leaf mass [kg].""" self.constants = constants - """The animals constants.""" - # Calculate energy availability - # TODO - this needs to be handed back to the plants model, which will define PFT - # specific conversions to different resources. - self.energy_density: float = self.constants.energy_density["plant"] - """The energy (J) in a kg of plant [currently set to toy value of Alfalfa].""" - self.energy_max: float = self.mass_current * self.energy_density - """The maximum amount of energy that the cohort can have [J] [Alfalfa].""" - self.stored_energy = self.energy_max - """The amount of energy in the plant cohort [J] [toy].""" - self.is_alive: bool = True - """Whether the cohort is alive [True] or dead [False].""" + """The animal constants, including energy density.""" + self.is_alive = True + """Indicating whether the plant cohort is alive [True] or dead [False].""" def get_eaten( self, consumed_mass: float, herbivore: Consumer, - excrement_pools: Sequence[ExcrementPool], + excrement_pools: Sequence[DecayPool], ) -> float: - """This function handles herbivory on PlantResources.""" - + """Handles herbivory on PlantResources, transfers excess to excrement pools. + + Args: + consumed_mass: The amount of mass consumed by the herbivore [kg]. + herbivore: The herbivore consuming the plant resource, used to access its + functional group properties such as mechanical efficiency and + conversion efficiency. + excrement_pools: A sequence of excrement pools to which excess mass (carbon) + will be added. + + Returns: + The net mass gain of the herbivore after considering mechanical and + digestive efficiencies [kg]. + """ # Check if the requested consumed mass is more than the available mass actual_consumed_mass = min(self.mass_current, consumed_mass) # Update the plant mass to reflect the mass consumed self.mass_current -= actual_consumed_mass - # Calculate the energy value of the consumed plants after mechanical efficiency + # Calculate the mass value of the consumed plants after mechanical efficiency effective_mass_for_herbivore = ( actual_consumed_mass * herbivore.functional_group.mechanical_efficiency ) - # Excess mass goes to the excrement pool, considering only the part not - # converted by mechanical efficiency + # Excess mass goes to the excrement pool excess_mass = actual_consumed_mass * ( 1 - herbivore.functional_group.mechanical_efficiency ) - # Calculate the energy to be added to each excrement pool - excreta_energy_per_pool = ( - excess_mass * self.constants.energy_density["plant"] - ) / len(excrement_pools) - - # Distribute the excreta energy across the excrement pools + # Distribute the excess mass as carbon across the excrement pools + excreta_mass_per_pool = excess_mass / len(excrement_pools) for excrement_pool in excrement_pools: - excrement_pool.decomposed_carbon += excreta_energy_per_pool + excrement_pool.decomposed_carbon += excreta_mass_per_pool # Return the net mass gain of herbivory, considering both mechanical and - # digestive efficiencies + # digestive efficiencies net_mass_gain = ( effective_mass_for_herbivore * herbivore.functional_group.conversion_efficiency diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index 29f6abf3a..b116f8b69 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -3,9 +3,10 @@ :mod:`~virtual_ecosystem.models.animal` module. """ # noqa: D205 +from collections.abc import Sequence from typing import Protocol -from virtual_ecosystem.models.animal.decay import ExcrementPool +# from virtual_ecosystem.models.animal.decay import ExcrementPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -44,7 +45,7 @@ class Resource(Protocol): mass_current: float def get_eaten( - self, consumed_mass: float, consumer: Consumer, pool: list[ExcrementPool] + self, consumed_mass: float, herbivore: Consumer, pool: Sequence[DecayPool] ) -> float: """The get_eaten method defines a resource.""" ... From ff0f939b4bdb8167faf11cc479a43b5717a74b43 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 11 Oct 2024 15:06:18 +0100 Subject: [PATCH 50/62] Further integration of decay module updates into multi-grid format. --- .../models/animal/animal_cohorts.py | 199 +++++++++++++----- .../models/animal/animal_model.py | 6 +- virtual_ecosystem/models/animal/constants.py | 40 ++-- virtual_ecosystem/models/animal/decay.py | 58 +++++ 4 files changed, 239 insertions(+), 64 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 01cd7f92a..16c676ede 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -12,7 +12,11 @@ from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_traits import DietType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool +from virtual_ecosystem.models.animal.decay import ( + CarcassPool, + ExcrementPool, + find_decay_consumed_split, +) from virtual_ecosystem.models.animal.functional_group import FunctionalGroup from virtual_ecosystem.models.animal.protocols import Resource @@ -87,9 +91,15 @@ def __init__( """The list of grid cells currently occupied by the cohort.""" # TODO - In future this should be parameterised using a constants dataclass, but # this hasn't yet been implemented for the animal model - self.decay_fraction_excrement: float = self.constants.decay_fraction_excrement + self.decay_fraction_excrement: float = find_decay_consumed_split( + microbial_decay_rate=self.constants.decay_rate_excrement, + animal_scavenging_rate=self.constants.scavenging_rate_excrement, + ) """The fraction of excrement which decays before it gets consumed.""" - self.decay_fraction_carcasses: float = self.constants.decay_fraction_carcasses + self.decay_fraction_carcasses: float = find_decay_consumed_split( + microbial_decay_rate=self.constants.decay_rate_carcasses, + animal_scavenging_rate=self.constants.scavenging_rate_carcasses, + ) """The fraction of carcass biomass which decays before it gets consumed.""" def get_territory_cells(self, centroid_key: int) -> list[int]: @@ -185,35 +195,50 @@ def metabolize(self, temperature: float, dt: timedelta64) -> float: def excrete( self, excreta_mass: float, excrement_pools: list[ExcrementPool] ) -> None: - """Transfers nitrogenous metabolic wastes to the excrement pool. + """Transfers metabolic wastes to the excrement pool. - This method will not be fully implemented until the stoichiometric rework. All - current metabolic wastes are carbonaceous and so all this does is provide a link - joining metabolism to a soil pool for later use. - - TODO: Update with stoichiometry + This method handles nitrogenous and carbonaceous wastes, split between + scavengeable and decomposed pools. Pending rework of stoichiometric + calculations. Args: - excreta_mass: The total mass of carbonaceous wastes excreted by the cohort. - excrement_pools: The pools of waste to which the excreted nitrogenous wastes - flow. - + excreta_mass: The total mass of wastes excreted by the cohort. + excrement_pools: The pools of waste to which the excreted wastes flow. """ - # the number of communities over which the feces are to be distributed number_communities = len(excrement_pools) - excreta_mass_per_community = ( - excreta_mass / number_communities - ) * self.constants.nitrogen_excreta_proportion + # Calculate excreta mass per community and proportionate nitrogen flow + excreta_mass_per_community = excreta_mass / number_communities + nitrogen_mass_per_community = ( + excreta_mass_per_community * self.constants.nitrogen_excreta_proportion + ) + + # Calculate scavengeable and decomposed nitrogen + scavengeable_nitrogen_per_community = ( + 1 - self.decay_fraction_excrement + ) * nitrogen_mass_per_community + decomposed_nitrogen_per_community = ( + self.decay_fraction_excrement * nitrogen_mass_per_community + ) + + # Carbon and phosphorus are fractions of nitrogen per community + scavengeable_carbon_per_community = 0.5 * scavengeable_nitrogen_per_community + decomposed_carbon_per_community = 0.5 * decomposed_nitrogen_per_community + scavengeable_phosphorus_per_community = ( + 0.01 * scavengeable_nitrogen_per_community + ) + decomposed_phosphorus_per_community = 0.01 * decomposed_nitrogen_per_community for excrement_pool in excrement_pools: - # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_carbon += ( - 1 - self.decay_fraction_excrement - ) * excreta_mass_per_community - excrement_pool.decomposed_carbon += ( - self.decay_fraction_excrement * excreta_mass_per_community + # Assign calculated nitrogen, carbon, and phosphorus to the pool + excrement_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_community + excrement_pool.decomposed_nitrogen += decomposed_nitrogen_per_community + excrement_pool.scavengeable_carbon += scavengeable_carbon_per_community + excrement_pool.decomposed_carbon += decomposed_carbon_per_community + excrement_pool.scavengeable_phosphorus += ( + scavengeable_phosphorus_per_community ) + excrement_pool.decomposed_phosphorus += decomposed_phosphorus_per_community def respire(self, excreta_mass: float) -> float: """Transfers carbonaceous metabolic wastes to the atmosphere. @@ -241,40 +266,75 @@ def defecate( ) -> None: """Transfer waste mass from an animal cohort to the excrement pools. - Currently, this function is in an inbetween state where mass is removed from - the animal cohort but it is recieved by the litter pool as energy. This will be - fixed once the litter pools are updated for mass. + Waste mass is transferred to the excrement pool(s), split between decomposed and + scavengable compartments. Carbon, nitrogen, and phosphorus are transferred + according to stoichiometric ratios. Mass is distributed over multiple excrement + pools if provided. - TODO: Rework after update litter pools for mass - TODO: update for current conversion efficiency - TODO: Update with stoichiometry + TODO: Needs to be reworked to use carbon mass rather than total mass. + TODO: Update with current conversion efficiency and stoichiometry. Args: excrement_pools: The ExcrementPool objects in the cohort's territory in which waste is deposited. mass_consumed: The amount of mass flowing through cohort digestion. """ - # the number of communities over which the feces are to be distributed number_communities = len(excrement_pools) - # Find total waste mass, the total amount of waste is found by the - # average cohort member * number individuals. - waste_mass = ( + # Calculate the total waste mass, which is the mass consumed times conversion + # efficiency + total_waste_mass = ( mass_consumed * self.functional_group.conversion_efficiency * self.individuals ) - waste_mass_per_community = waste_mass / number_communities + # Split the waste mass proportionally among communities + waste_mass_per_community = total_waste_mass / number_communities + + # Calculate waste for carbon, nitrogen, and phosphorus using current + # stoichiometry + waste_carbon_per_community = waste_mass_per_community + waste_nitrogen_per_community = 0.1 * waste_carbon_per_community + waste_phosphorus_per_community = 0.01 * waste_carbon_per_community + + # Pre-calculate the scavengeable and decomposed fractions for each nutrient + scavengeable_carbon_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_carbon_per_community + decomposed_carbon_per_community = ( + self.decay_fraction_excrement * waste_carbon_per_community + ) + scavengeable_nitrogen_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_nitrogen_per_community + decomposed_nitrogen_per_community = ( + self.decay_fraction_excrement * waste_nitrogen_per_community + ) + + scavengeable_phosphorus_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_phosphorus_per_community + decomposed_phosphorus_per_community = ( + self.decay_fraction_excrement * waste_phosphorus_per_community + ) + + # Distribute waste across each excrement pool for excrement_pool in excrement_pools: - # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_carbon += ( - 1 - self.decay_fraction_excrement - ) * waste_mass_per_community - excrement_pool.decomposed_carbon += ( - self.decay_fraction_excrement * waste_mass_per_community + # Update carbon pools + excrement_pool.scavengeable_carbon += scavengeable_carbon_per_community + excrement_pool.decomposed_carbon += decomposed_carbon_per_community + + # Update nitrogen pools + excrement_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_community + excrement_pool.decomposed_nitrogen += decomposed_nitrogen_per_community + + # Update phosphorus pools + excrement_pool.scavengeable_phosphorus += ( + scavengeable_phosphorus_per_community ) + excrement_pool.decomposed_phosphorus += decomposed_phosphorus_per_community def increase_age(self, dt: timedelta64) -> None: """The function to modify cohort age as time passes and flag maturity. @@ -330,26 +390,65 @@ def die_individual( def update_carcass_pool( self, carcass_mass: float, carcass_pools: list[CarcassPool] ) -> None: - """Updates the carcass pools based on consumed mass and predator's efficiency. + """Updates the carcass pools after deaths. + + Carcass mass is transferred to the carcass pools, split between a decomposed and + a scavengeable compartment. Carbon, nitrogen, and phosphorus are all transferred + according to stoichiometric ratios. - TODO: move to animal model? + TODO: Update to handle proper carbon mass rather than total mass. + TODO: Use dynamic stoichiometry once implemented. Args: carcass_mass: The total mass consumed from the prey cohort. carcass_pools: The pools to which remains of eaten individuals are - delivered. + delivered. """ number_carcass_pools = len(carcass_pools) + + # Split carcass mass per pool carcass_mass_per_pool = carcass_mass / number_carcass_pools + # Calculate stoichiometric proportions for nitrogen and phosphorus + carcass_mass_nitrogen_per_pool = 0.1 * carcass_mass_per_pool + carcass_mass_phosphorus_per_pool = 0.01 * carcass_mass_per_pool + + # Pre-calculate scavengeable and decomposed fractions for carbon, nitrogen, + # and phosphorus + scavengeable_carbon_per_pool = ( + 1 - self.decay_fraction_carcasses + ) * carcass_mass_per_pool + decomposed_carbon_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_per_pool + ) + + scavengeable_nitrogen_per_pool = ( + 1 - self.decay_fraction_carcasses + ) * carcass_mass_nitrogen_per_pool + decomposed_nitrogen_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_nitrogen_per_pool + ) + + scavengeable_phosphorus_per_pool = ( + 1 - self.decay_fraction_carcasses + ) * carcass_mass_phosphorus_per_pool + decomposed_phosphorus_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_phosphorus_per_pool + ) + + # Distribute carcass mass across the carcass pools for carcass_pool in carcass_pools: - # Update the carcass pool with the remainder - carcass_pool.scavengeable_carbon += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass_per_pool - carcass_pool.decomposed_carbon += ( - self.decay_fraction_carcasses * carcass_mass_per_pool - ) + # Update carbon pools + carcass_pool.scavengeable_carbon += scavengeable_carbon_per_pool + carcass_pool.decomposed_carbon += decomposed_carbon_per_pool + + # Update nitrogen pools + carcass_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_pool + carcass_pool.decomposed_nitrogen += decomposed_nitrogen_per_pool + + # Update phosphorus pools + carcass_pool.scavengeable_phosphorus += scavengeable_phosphorus_per_pool + carcass_pool.decomposed_phosphorus += decomposed_phosphorus_per_pool def get_eaten( self, diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 8dd1f8f34..9e4dcb5e8 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -350,9 +350,13 @@ def update(self, time_index: int, **kwargs: Any) -> None: # soil and litter models can be extracted additions_to_soil = self.calculate_soil_additions() litter_consumption = self.calculate_total_litter_consumption(litter_pools) + # litter_additions = self.calculate_litter_additions_from_herbivory() # Update the data object with the changes to soil and litter pools - self.data.add_from_dict(additions_to_soil | litter_consumption) + self.data.add_from_dict( + additions_to_soil | litter_consumption # | litter_additions + ) # TODO - TEST THIS! + # Update population densities self.update_population_densities() diff --git a/virtual_ecosystem/models/animal/constants.py b/virtual_ecosystem/models/animal/constants.py index 4e5c3e5c4..ab59672c6 100644 --- a/virtual_ecosystem/models/animal/constants.py +++ b/virtual_ecosystem/models/animal/constants.py @@ -214,29 +214,43 @@ class AnimalConsts(ConstantsDataclass): """The mortality proportion inflicted on a larval cohort undergoing metamorphosis. """ - carbon_excreta_proportion = 1.0 # toy [unitless] + carbon_excreta_proportion = 0.9 # toy [unitless] """The proportion of metabolic wastes that are carbonaceous. This is a temporary fix to facilitate building the machinery and will be updated with stoichiometry.""" - nitrogen_excreta_proportion = 0.0 # toy [unitless] + nitrogen_excreta_proportion = 0.1 # toy [unitless] """The proportion of metabolic wastes that are nitrogenous. This is a temporary fix to facilitate building the machinery and will be updated with stoichiometry.""" + decay_rate_excrement: float = 0.25 + """Rate at which excrement decays due to microbial activity [day^-1]. + + In reality this should not be constant, but as a simplifying assumption it is. + """ + + scavenging_rate_excrement: float = 0.25 + """Rate at which excrement is scavenged by animals [day^-1]. -DECAY_FRACTION_EXCREMENT: float = 0.5 -"""Fraction of excrement that is assumed to decay rather than be consumed [unitless]. + Used along with :attr:`decay_rate_excrement` to calculate the split of excrement + between scavengable excrement and flow into the soil. In reality this should be a + constant, but as a simplifying assumption it is. + """ -TODO - The number given here is very much made up. In future, we either need to find a -way of estimating this from data, or come up with a smarter way of handling this -process. -""" + decay_rate_carcasses: float = 0.0625 + """Rate at which carcasses decay due to microbial activity [day^-1]. + + In reality this should not be constant, but as a simplifying assumption it is. + """ + + scavenging_rate_carcasses: float = 0.25 + """Rate at which carcasses are scavenged by animals [day^-1]. + + Used along with :attr:`decay_rate_carcasses` to calculate the split of carcass + biomass between scavengable carcass biomass and flow into the soil. In reality this + should be a constant, but as a simplifying assumption it is. + """ -DECAY_FRACTION_CARCASSES: float = 0.2 -"""Fraction of carcass biomass that is assumed to decay rather than be consumed. -[unitless]. TODO - The number given here is very much made up, see -:attr:`DECAY_FRACTION_EXCREMENT` for details of how this should be changed in future. -""" BOLTZMANN_CONSTANT: float = 8.617333262145e-5 # Boltzmann constant [eV/K] TEMPERATURE: float = 37.0 # Toy temperature for setting up metabolism [C]. diff --git a/virtual_ecosystem/models/animal/decay.py b/virtual_ecosystem/models/animal/decay.py index 5548d3abf..533b095d9 100644 --- a/virtual_ecosystem/models/animal/decay.py +++ b/virtual_ecosystem/models/animal/decay.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from virtual_ecosystem.core.data import Data +from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.protocols import Consumer @@ -136,6 +137,9 @@ class LitterPool: """ def __init__(self, pool_name: str, data: Data, cell_area: float) -> None: + self.pool_name = pool_name + """Name of the pool.""" + self.mass_current = (data[f"litter_pool_{pool_name}"].to_numpy()) * cell_area """Mass of the litter pool in carbon terms [kg C].""" @@ -177,3 +181,57 @@ def get_eaten( ) return net_mass_gain + + +class HerbivoryWaste: + """A class to track the amount of waste generated by each form of herbivory. + + This is used as a temporary storage location before the wastes are added to the + litter model. As such it is not made available for animal consumption. + + The litter model splits its plant matter into four classes: wood, leaves, roots, and + reproductive tissues (fruits and flowers). A separate instance of this class should + be used for each of these groups. + + Args: + pool_name: Type of plant matter this waste pool contains. + + Raises: + ValueError: If initialised for a plant matter type that the litter model doesn't + accept. + """ + + def __init__(self, plant_matter_type: str) -> None: + # Check that this isn't being initialised for a plant matter type that the + # litter model doesn't use + accepted_plant_matter_types = [ + "leaf", + "root", + "deadwood", + "reproductive_tissue", + ] + if plant_matter_type not in accepted_plant_matter_types: + to_raise = ValueError( + f"{plant_matter_type} not a valid form of herbivory waste, valid forms " + f"are as follows: {accepted_plant_matter_types}" + ) + LOGGER.error(to_raise) + raise to_raise + + self.plant_matter_type = plant_matter_type + """Type of plant matter this waste pool contains.""" + + self.mass_current = 0.0 + """Mass of the herbivory waste pool in carbon terms [kg C].""" + + # TODO - These are all hard coded, but once herbivory is properly implemented + # they should be updated based on the stoichiometry of the actual plant matter + # consumed. + self.c_n_ratio = 20.0 + """Ratio of carbon to nitrogen of the herbivory waste pool [unitless].""" + + self.c_p_ratio = 150.0 + """Ratio of carbon to phosphorus of the herbivory waste pool [unitless].""" + + self.lignin_proportion = 0.25 + """Proportion of the herbivory waste pool carbon that is lignin [unitless].""" From 4c9c76e26072e898210e6e19d1ffdf55b998f93f Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Thu, 24 Oct 2024 16:50:45 +0100 Subject: [PATCH 51/62] Integrated new decay methods to animal model and cleaned up related testing errors. --- tests/models/animals/test_animal_model.py | 4 + .../models/animal/animal_model.py | 86 +++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 60b5eece3..45fd33cfa 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -95,6 +95,10 @@ def test_animal_model_initialization( INFO, "Adding data array for 'litter_consumption_below_structural'", ), + (INFO, "Adding data array for 'herbivory_waste_leaf_carbon'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_nitrogen'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_phosphorus'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_lignin'"), ), id="success", ), diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 9e4dcb5e8..12ee983ab 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -35,7 +35,12 @@ from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort from virtual_ecosystem.models.animal.animal_traits import DevelopmentType, DietType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import CarcassPool, ExcrementPool, LitterPool +from virtual_ecosystem.models.animal.decay import ( + CarcassPool, + ExcrementPool, + HerbivoryWaste, + LitterPool, +) from virtual_ecosystem.models.animal.functional_group import ( FunctionalGroup, get_functional_group_by_name, @@ -75,6 +80,10 @@ class AnimalModel( "decomposed_carcasses_carbon", "decomposed_carcasses_nitrogen", "decomposed_carcasses_phosphorus", + "herbivory_waste_leaf_carbon", + "herbivory_waste_leaf_nitrogen", + "herbivory_waste_leaf_phosphorus", + "herbivory_waste_leaf_lignin", "litter_consumption_above_metabolic", "litter_consumption_above_structural", "litter_consumption_woody", @@ -88,6 +97,10 @@ class AnimalModel( "decomposed_carcasses_carbon", "decomposed_carcasses_nitrogen", "decomposed_carcasses_phosphorus", + "herbivory_waste_leaf_carbon", + "herbivory_waste_leaf_nitrogen", + "herbivory_waste_leaf_phosphorus", + "herbivory_waste_leaf_lignin", "total_animal_respiration", "litter_consumption_above_metabolic", "litter_consumption_above_structural", @@ -172,7 +185,11 @@ def __init__( for cell_id in self.data.grid.cell_id } """The carcass pools in the model with associated grid cell ids.""" - + self.leaf_waste_pools: dict[int, HerbivoryWaste] = { + cell_id: HerbivoryWaste(plant_matter_type="leaf") + for cell_id in self.data.grid.cell_id + } + """A pool for leaves removed by herbivory but not actually consumed.""" self.cohorts: dict[UUID, AnimalCohort] = {} """A dictionary of all animal cohorts and their unique ids.""" self.communities: dict[int, list[AnimalCohort]] = { @@ -350,11 +367,11 @@ def update(self, time_index: int, **kwargs: Any) -> None: # soil and litter models can be extracted additions_to_soil = self.calculate_soil_additions() litter_consumption = self.calculate_total_litter_consumption(litter_pools) - # litter_additions = self.calculate_litter_additions_from_herbivory() + litter_additions = self.calculate_litter_additions_from_herbivory() # Update the data object with the changes to soil and litter pools self.data.add_from_dict( - additions_to_soil | litter_consumption # | litter_additions + additions_to_soil | litter_consumption | litter_additions ) # TODO - TEST THIS! # Update population densities @@ -366,7 +383,16 @@ def cleanup(self) -> None: def populate_litter_pools(self) -> dict[str, LitterPool]: """Populate the litter pools that animals can consume from. - TODO: rework for merge + Returns: + dict[str, LitterPool]: A dictionary where keys represent the pool types and + values are the corresponding `LitterPool` objects. The following pools are + included: + + - "above_metabolic": Litter pool for above-ground metabolic organic matter + - "above_structural": Litter pool for above-ground structural organic matter + - "woody": Litter pool for woody biomass + - "below_metabolic": Litter pool for below-ground metabolic organic matter + - "below_structural": Litter pool for below-ground structural organic matter """ @@ -426,6 +452,56 @@ def calculate_total_litter_consumption( for pool_name in litter_pools.keys() } + def calculate_litter_additions_from_herbivory(self) -> dict[str, DataArray]: + """Calculate additions to litter due to herbivory mechanical inefficiencies. + + TODO - At present the only type of herbivory this works for is leaf herbivory, + that should be changed once herbivory as a whole is fleshed out. + TODO: rework for merge + + Returns: + A dictionary containing details of the leaf litter addition due to herbivory + this comprises of the mass added in carbon terms [kg C m^-2], ratio of + carbon to nitrogen [unitless], ratio of carbon to phosphorus [unitless], and + the proportion of input carbon that is lignin [unitless]. + """ + + # Find the size of the leaf waste pool (in carbon terms) + leaf_addition = [ + self.leaf_waste_pools[cell_id].mass_current / self.data.grid.cell_area + for cell_id in self.data.grid.cell_id + ] + # Find the chemistry of the pools as well + leaf_c_n = [ + self.leaf_waste_pools[cell_id].c_n_ratio + for cell_id in self.data.grid.cell_id + ] + leaf_c_p = [ + self.leaf_waste_pools[cell_id].c_p_ratio + for cell_id in self.data.grid.cell_id + ] + leaf_lignin = [ + self.leaf_waste_pools[cell_id].lignin_proportion + for cell_id in self.data.grid.cell_id + ] + + # Reset all of the herbivory waste pools to zero + for waste in self.leaf_waste_pools.values(): + waste.mass_current = 0.0 + + return { + "herbivory_waste_leaf_carbon": DataArray( + array(leaf_addition), dims="cell_id" + ), + "herbivory_waste_leaf_nitrogen": DataArray(array(leaf_c_n), dims="cell_id"), + "herbivory_waste_leaf_phosphorus": DataArray( + array(leaf_c_p), dims="cell_id" + ), + "herbivory_waste_leaf_lignin": DataArray( + array(leaf_lignin), dims="cell_id" + ), + } + def calculate_soil_additions(self) -> dict[str, DataArray]: """Calculate how much animal matter should be transferred to the soil.""" From 7c12452f26319a220e89379754ea07d7f7a7a1a7 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Fri, 25 Oct 2024 11:28:12 +0100 Subject: [PATCH 52/62] Updated cohort herbivory methods for new herbivorywaste pools. --- .../models/animal/animal_cohorts.py | 40 +++++++++++++++- .../models/animal/animal_model.py | 4 ++ .../models/animal/plant_resources.py | 48 ++++++++----------- virtual_ecosystem/models/animal/protocols.py | 8 ++-- 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 16c676ede..8d6e3195a 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -15,6 +15,7 @@ from virtual_ecosystem.models.animal.decay import ( CarcassPool, ExcrementPool, + HerbivoryWaste, find_decay_consumed_split, ) from virtual_ecosystem.models.animal.functional_group import FunctionalGroup @@ -820,15 +821,21 @@ def delta_mass_herbivory( self, plant_list: list[Resource], excrement_pools: list[ExcrementPool], + herbivory_waste_pools: dict[int, HerbivoryWaste], ) -> float: """This method handles mass assimilation from herbivory. TODO: rethink defecate location + TODO: At present this just takes a single herbivory waste pool (for leaves), + this probably should change to be a list of waste pools once herbivory for other + plant tissues is added. TODO: update name Args: plant_list: A list of plant resources available for herbivory. excrement_pools: The pools representing the excrement in the territory. + herbivory_waste_pools: Waste pools for plant biomass (at this point just + leaves) that gets removed as part of herbivory but not actually consumed. Returns: A float of the total plant mass consumed by the animal cohort in g. @@ -840,9 +847,10 @@ def delta_mass_herbivory( # Calculate the mass to be consumed from this plant consumed_mass = self.calculate_consumed_mass_herbivory(plant_list, plant) # Update the plant resource's state based on consumed mass - actual_consumed_mass = plant.get_eaten(consumed_mass, self, excrement_pools) + actual_consumed_mass, excess_mass = plant.get_eaten(consumed_mass, self) # Update total mass gained by the herbivore total_consumed_mass += actual_consumed_mass + herbivory_waste_pools[plant.cell_id].mass_current += excess_mass # Process waste generated from predation, separate from predation b/c diff waste self.defecate(excrement_pools, total_consumed_mass) @@ -855,6 +863,7 @@ def forage_cohort( animal_list: list[AnimalCohort], excrement_pools: list[ExcrementPool], carcass_pools: dict[int, list[CarcassPool]], + herbivory_waste_pools: dict[int, HerbivoryWaste], ) -> None: """This function handles selection of resources from a list for consumption. @@ -863,6 +872,8 @@ def forage_cohort( animal_list: A list of animal cohorts available for predation. excrement_pools: The pools representing the excrement in the grid cell. carcass_pools: The pools to which animal carcasses are delivered. + herbivory_waste_pools: A dict of pools representing waste caused by + herbivory. Return: A float value of the net change in consumer mass due to foraging. @@ -878,7 +889,7 @@ def forage_cohort( # Herbivore diet if self.functional_group.diet == DietType.HERBIVORE and plant_list: consumed_mass = self.delta_mass_herbivory( - plant_list, excrement_pools + plant_list, excrement_pools, herbivory_waste_pools ) # Directly modifies the plant mass self.eat(consumed_mass) # Accumulate net mass gain from each plant @@ -1147,6 +1158,31 @@ def get_excrement_pools( 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. + + 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 get_carcass_pools( self, carcass_pools: dict[int, list[CarcassPool]] ) -> list[CarcassPool]: diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 12ee983ab..b95d42429 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -851,6 +851,9 @@ def forage_community(self) -> None: plant_list = [] prey_list = [] excrement_list = consumer_cohort.get_excrement_pools(self.excrement_pools) + """plant_waste_list = consumer_cohort.get_plant_waste_pools( + self.leaf_waste_pools + )""" # Check the diet of the cohort and get appropriate resources if consumer_cohort.functional_group.diet == DietType.HERBIVORE: @@ -865,6 +868,7 @@ def forage_community(self) -> None: animal_list=prey_list, excrement_pools=excrement_list, carcass_pools=self.carcass_pools, # the full list of carcass pools + herbivory_waste_pools=self.leaf_waste_pools, # full list of leaf waste ) # Temporary solution to remove dead cohorts diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 5c7441836..b409f3b3c 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -4,13 +4,11 @@ from __future__ import annotations -from collections.abc import Sequence - from virtual_ecosystem.core.data import Data from virtual_ecosystem.models.animal.constants import AnimalConsts # from virtual_ecosystem.models.animal.decay import ExcrementPool -from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool +from virtual_ecosystem.models.animal.protocols import Consumer class PlantResources: @@ -37,7 +35,9 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: """A reference to the core data object.""" self.cell_id = cell_id """The community cell containing the plant resources.""" - self.mass_current = 100000.0 + self.mass_current: float = ( + data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() + ) """The mass of the plant leaf mass [kg].""" self.constants = constants """The animal constants, including energy density.""" @@ -45,24 +45,22 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: """Indicating whether the plant cohort is alive [True] or dead [False].""" def get_eaten( - self, - consumed_mass: float, - herbivore: Consumer, - excrement_pools: Sequence[DecayPool], - ) -> float: - """Handles herbivory on PlantResources, transfers excess to excrement pools. + self, consumed_mass: float, herbivore: Consumer + ) -> tuple[float, float]: + """This function handles herbivory on PlantResources. + + TODO: the plant waste here is specifically leaf litter, alternative functions + (or classes) will need to be written for consumption of roots and reproductive + tissues (fruits and flowers). Args: - consumed_mass: The amount of mass consumed by the herbivore [kg]. - herbivore: The herbivore consuming the plant resource, used to access its - functional group properties such as mechanical efficiency and - conversion efficiency. - excrement_pools: A sequence of excrement pools to which excess mass (carbon) - will be added. + consumed_mass: The mass intended to be consumed by the herbivore. + herbivore: The Consumer (AnimalCohort) consuming the PlantResources. Returns: - The net mass gain of the herbivore after considering mechanical and - digestive efficiencies [kg]. + A tuple consisting of the actual mass consumed by the herbivore (adjusted + for efficiencies), and the mass removed from the plants by herbivory that + isn't consumed and instead becomes litter. """ # Check if the requested consumed mass is more than the available mass actual_consumed_mass = min(self.mass_current, consumed_mass) @@ -70,26 +68,22 @@ def get_eaten( # Update the plant mass to reflect the mass consumed self.mass_current -= actual_consumed_mass - # Calculate the mass value of the consumed plants after mechanical efficiency + # Calculate the energy value of the consumed plants after mechanical efficiency effective_mass_for_herbivore = ( actual_consumed_mass * herbivore.functional_group.mechanical_efficiency ) - # Excess mass goes to the excrement pool + # Excess mass goes to the excrement pool, considering only the part not + # converted by mechanical efficiency excess_mass = actual_consumed_mass * ( 1 - herbivore.functional_group.mechanical_efficiency ) - # Distribute the excess mass as carbon across the excrement pools - excreta_mass_per_pool = excess_mass / len(excrement_pools) - for excrement_pool in excrement_pools: - excrement_pool.decomposed_carbon += excreta_mass_per_pool - # Return the net mass gain of herbivory, considering both mechanical and - # digestive efficiencies + # digestive efficiencies net_mass_gain = ( effective_mass_for_herbivore * herbivore.functional_group.conversion_efficiency ) - return net_mass_gain + return net_mass_gain, excess_mass diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index b116f8b69..07d41d54a 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -3,7 +3,6 @@ :mod:`~virtual_ecosystem.models.animal` module. """ # noqa: D205 -from collections.abc import Sequence from typing import Protocol # from virtual_ecosystem.models.animal.decay import ExcrementPool @@ -43,9 +42,12 @@ class Resource(Protocol): """This is the protocol for defining what classes work as trophic resources.""" mass_current: float + cell_id: int def get_eaten( - self, consumed_mass: float, herbivore: Consumer, pool: Sequence[DecayPool] - ) -> float: + self, + consumed_mass: float, + herbivore: Consumer, + ) -> tuple[float, float]: """The get_eaten method defines a resource.""" ... From cc1e8ad4cbc4c301213d6fee77f491af6632f54e Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 28 Oct 2024 13:24:57 +0000 Subject: [PATCH 53/62] Finished revising AnimalCohort tests. --- tests/models/animals/conftest.py | 19 +++ tests/models/animals/test_animal_cohorts.py | 147 ++++++++++-------- tests/models/animals/test_animal_model.py | 2 + .../models/animal/plant_resources.py | 4 +- 4 files changed, 108 insertions(+), 64 deletions(-) diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 8c806fcc4..e03710d23 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -820,3 +820,22 @@ def litter_pool_instance(litter_data_instance): data=litter_data_instance, cell_area=10000, ) + + +@pytest.fixture +def herbivory_waste_pool_instance(): + """Fixture for a herbivory waste pool class to be used in tests.""" + from virtual_ecosystem.models.animal.decay import HerbivoryWaste + + # Create an instance of HerbivoryWaste with the valid plant_matter_type + herbivory_waste = HerbivoryWaste(plant_matter_type="leaf") + + # Manually set the additional attributes + herbivory_waste.mass_current = 0.5 # Initial mass in kg + herbivory_waste.c_n_ratio = 20.0 # Carbon to Nitrogen ratio [unitless] + herbivory_waste.c_p_ratio = 150.0 # Carbon to Phosphorus ratio [unitless] + herbivory_waste.lignin_proportion = ( + 0.25 # Proportion of lignin in the mass [unitless] + ) + + return herbivory_waste diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 5258ad235..5fcec097e 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -229,64 +229,45 @@ def test_metabolize( assert isclose(cohort_instance.mass_current, expected_final_mass, rtol=1e-9) @pytest.mark.parametrize( - "cohort_type, excreta_mass, initial_pool_carbon, num_pools," - "expected_pool_carbon", + "cohort_type, excreta_mass, initial_pool_carbon, num_pools", [ - ( - "herbivore", - 100.0, - 500.0, - 1, - 500.0, - ), # normal case for herbivore with one pool + ("herbivore", 100.0, 500.0, 1), # normal case for herbivore with one pool ( "herbivore", 0.0, 500.0, 1, - 500.0, ), # zero excreta mass for herbivore with one pool - ( - "ectotherm", - 50.0, - 300.0, - 1, - 300.0, - ), # normal case for ectotherm with one pool + ("ectotherm", 50.0, 300.0, 1), # normal case for ectotherm with one pool ( "ectotherm", 0.0, 300.0, 1, - 300.0, ), # zero excreta mass for ectotherm with one pool ( "herbivore", 100.0, 500.0, 3, - 500.0, ), # normal case for herbivore with multiple pools ( "herbivore", 0.0, 500.0, 3, - 500.0, ), # zero excreta mass for herbivore with multiple pools ( "ectotherm", 50.0, 300.0, 3, - 300.0, ), # normal case for ectotherm with multiple pools ( "ectotherm", 0.0, 300.0, 3, - 300.0, ), # zero excreta mass for ectotherm with multiple pools ], ids=[ @@ -309,59 +290,67 @@ def test_excrete( excreta_mass, initial_pool_carbon, num_pools, - expected_pool_carbon, ): """Testing excrete method for various scenarios.""" + from virtual_ecosystem.models.animal.decay import ExcrementPool + # Select the appropriate cohort instance - if cohort_type == "herbivore": - cohort_instance = herbivore_cohort_instance - elif cohort_type == "ectotherm": - cohort_instance = ectotherm_cohort_instance - else: - raise ValueError("Invalid cohort type provided.") + cohort_instance = ( + herbivore_cohort_instance + if cohort_type == "herbivore" + else ectotherm_cohort_instance + ) # Mock the excrement pools excrement_pools = [] for _ in range(num_pools): - excrement_pool = mocker.Mock() + excrement_pool = mocker.Mock(spec=ExcrementPool) + # Initialize the required attributes for the mock object excrement_pool.decomposed_carbon = initial_pool_carbon excrement_pool.scavengeable_carbon = initial_pool_carbon + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.scavengeable_nitrogen = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 + excrement_pool.scavengeable_phosphorus = 0.0 excrement_pools.append(excrement_pool) # Call the excrete method cohort_instance.excrete(excreta_mass, excrement_pools) - # Check the expected results for carbon pools - for excrement_pool in excrement_pools: - excreta_mass_per_community = ( - excreta_mass / num_pools - ) * cohort_instance.constants.nitrogen_excreta_proportion + # Expected results calculation + excreta_mass_per_community = excreta_mass / num_pools + nitrogen_mass_per_community = ( + excreta_mass_per_community + * cohort_instance.constants.nitrogen_excreta_proportion + ) + decay_fraction = cohort_instance.decay_fraction_excrement - expected_decomposed_carbon = ( - initial_pool_carbon - + cohort_instance.decay_fraction_excrement * excreta_mass_per_community - ) - expected_scavengeable_carbon = ( - initial_pool_carbon - + (1 - cohort_instance.decay_fraction_excrement) - * excreta_mass_per_community - ) + # Calculate expected decomposed and scavengeable carbon + expected_decomposed_carbon = ( + initial_pool_carbon + decay_fraction * 0.5 * nitrogen_mass_per_community + ) + expected_scavengeable_carbon = ( + initial_pool_carbon + + (1 - decay_fraction) * 0.5 * nitrogen_mass_per_community + ) + # Check assertions for carbon + for excrement_pool in excrement_pools: assert excrement_pool.decomposed_carbon == pytest.approx( - expected_decomposed_carbon + expected_decomposed_carbon, rel=1e-3 ) assert excrement_pool.scavengeable_carbon == pytest.approx( - expected_scavengeable_carbon + expected_scavengeable_carbon, rel=1e-3 ) @pytest.mark.parametrize( - "cohort_type, excreta_mass, expected_carbon_waste", + "cohort_type, excreta_mass", [ - ("herbivore", 100.0, 100.0), # normal case for herbivore - ("herbivore", 0.0, 0.0), # zero excreta mass for herbivore - ("ectotherm", 50.0, 50.0), # normal case for ectotherm - ("ectotherm", 0.0, 0.0), # zero excreta mass for ectotherm + ("herbivore", 100.0), # normal case for herbivore + ("herbivore", 0.0), # zero excreta mass for herbivore + ("ectotherm", 50.0), # normal case for ectotherm + ("ectotherm", 0.0), # zero excreta mass for ectotherm ], ids=[ "herbivore_normal", @@ -376,7 +365,6 @@ def test_respire( ectotherm_cohort_instance, cohort_type, excreta_mass, - expected_carbon_waste, ): """Testing respire method for various scenarios. @@ -392,6 +380,11 @@ def test_respire( else: raise ValueError("Invalid cohort type provided.") + # Calculate the expected carbon waste based on the cohort's constants + expected_carbon_waste = ( + excreta_mass * cohort_instance.constants.carbon_excreta_proportion + ) + # Call the respire method carbon_waste = cohort_instance.respire(excreta_mass) @@ -439,6 +432,10 @@ def test_defecate( excrement_pool = mocker.Mock() excrement_pool.scavengeable_carbon = scav_initial excrement_pool.decomposed_carbon = decomp_initial + excrement_pool.scavengeable_nitrogen = 0.0 + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.scavengeable_phosphorus = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 excrement_pools.append(excrement_pool) # Call the defecate method @@ -1244,6 +1241,7 @@ def test_delta_mass_herbivory( herbivore_cohort_instance, plant_list_instance, excrement_pool_instance, + herbivory_waste_pool_instance, ): """Test mass assimilation calculation from herbivory.""" @@ -1251,18 +1249,30 @@ def test_delta_mass_herbivory( mock_calculate_consumed_mass_herbivory = mocker.patch.object( herbivore_cohort_instance, "calculate_consumed_mass_herbivory", - side_effect=lambda plant_list, plant: 10.0, - # Assume 10.0 kg mass consumed from each plant for simplicity + side_effect=lambda plant_list, + plant: 10.0, # Assume 10.0 kg mass consumed from each plant for simplicity ) - # Mock the PlantResources.get_eaten method + # Mock the PlantResources.get_eaten method to match its original signature mock_get_eaten = mocker.patch( "virtual_ecosystem.models.animal.plant_resources.PlantResources.get_eaten", - side_effect=lambda consumed_mass, herbivore, excrement_pool: consumed_mass, + side_effect=lambda consumed_mass, herbivore: ( + consumed_mass, + 0.0, + ), # Return consumed_mass and 0.0 as excess_mass ) + # Ensure herbivory_waste_pools includes entries for all plant cell IDs + herbivory_waste_pools = { + plant.cell_id: herbivory_waste_pool_instance + for plant in plant_list_instance + } + + # Call the delta_mass_herbivory method delta_mass = herbivore_cohort_instance.delta_mass_herbivory( - plant_list_instance, [excrement_pool_instance] + plant_list_instance, + [excrement_pool_instance], + herbivory_waste_pools, ) # Ensure calculate_consumed_mass_herbivory and get_eaten were called correctly @@ -1274,7 +1284,7 @@ def test_delta_mass_herbivory( # Calculate the expected total consumed mass based on the number of plants expected_delta_mass = 10.0 * len(plant_list_instance) - # Assert the calculated delta_mass_herb matches the expected value + # Assert the calculated delta_mass matches the expected value assert delta_mass == pytest.approx( expected_delta_mass ), "Calculated change in mass due to herbivory did not match expected value." @@ -1288,6 +1298,7 @@ def test_forage_cohort( animal_list_instance, excrement_pool_instance, carcass_pools_instance, + herbivory_waste_pool_instance, ): """Test foraging behavior for different diet types.""" @@ -1301,18 +1312,32 @@ def test_forage_cohort( mock_eat_herbivore = mocker.patch.object(herbivore_cohort_instance, "eat") mock_eat_predator = mocker.patch.object(predator_cohort_instance, "eat") + # Ensure herbivory_waste_pools includes entries for all plant cell IDs + herbivory_waste_pools = { + plant.cell_id: herbivory_waste_pool_instance + for plant in plant_list_instance + } + # Test herbivore diet herbivore_cohort_instance.forage_cohort( - plant_list_instance, [], excrement_pool_instance, carcass_pools_instance + plant_list_instance, + [], + excrement_pool_instance, + carcass_pools_instance, + herbivory_waste_pools, ) mock_delta_mass_herbivory.assert_called_once_with( - plant_list_instance, excrement_pool_instance + plant_list_instance, excrement_pool_instance, herbivory_waste_pools ) mock_eat_herbivore.assert_called_once_with(100) # Test carnivore diet predator_cohort_instance.forage_cohort( - [], animal_list_instance, excrement_pool_instance, carcass_pools_instance + [], + animal_list_instance, + excrement_pool_instance, + carcass_pools_instance, + {}, ) mock_delta_mass_predation.assert_called_once_with( animal_list_instance, excrement_pool_instance, carcass_pools_instance diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 45fd33cfa..21b083829 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -1024,6 +1024,7 @@ def test_forage_community( animal_list=[], excrement_pools=["excrement_pools_herbivore"], carcass_pools=animal_model_instance.carcass_pools, + herbivory_waste_pools=animal_model_instance.leaf_waste_pools, ) # Verify that predators forage prey and not plant resources @@ -1034,6 +1035,7 @@ def test_forage_community( animal_list=["prey"], excrement_pools=["excrement_pools_predator"], carcass_pools=animal_model_instance.carcass_pools, + herbivory_waste_pools=animal_model_instance.leaf_waste_pools, ) def test_metabolize_community( diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index b409f3b3c..3b7615c92 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -35,9 +35,7 @@ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: """A reference to the core data object.""" self.cell_id = cell_id """The community cell containing the plant resources.""" - self.mass_current: float = ( - data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() - ) + self.mass_current: float = 10000.0 """The mass of the plant leaf mass [kg].""" self.constants = constants """The animal constants, including energy density.""" From c071d86a06f8c55c6930860628081fdc00074420 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Mon, 28 Oct 2024 13:38:56 +0000 Subject: [PATCH 54/62] Fixed plant resource test. --- tests/models/animals/test_plant_resources.py | 26 ++++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/tests/models/animals/test_plant_resources.py b/tests/models/animals/test_plant_resources.py index 2f6ab6c67..d3957154e 100644 --- a/tests/models/animals/test_plant_resources.py +++ b/tests/models/animals/test_plant_resources.py @@ -1,22 +1,19 @@ """Test module for plant_resources.py.""" +import pytest + class TestPlantResources: """Test Plant class.""" - def test_get_eaten( - self, plant_instance, herbivore_cohort_instance, excrement_pool_instance - ): + def test_get_eaten(self, plant_instance, herbivore_cohort_instance): """Test the get_eaten method for PlantResources.""" - import pytest - consumed_mass = 50.0 # Define a mass to be consumed for the test initial_mass_current = plant_instance.mass_current - initial_excrement_carbon = excrement_pool_instance.decomposed_carbon # Call the method - actual_mass_gain = plant_instance.get_eaten( - consumed_mass, herbivore_cohort_instance, [excrement_pool_instance] + actual_mass_gain, excess_mass = plant_instance.get_eaten( + consumed_mass, herbivore_cohort_instance ) # Check if the plant mass has been correctly reduced @@ -35,13 +32,10 @@ def test_get_eaten( expected_mass_gain ), "Actual mass gain should match expected value after efficiency adjustments." - # Check if the excess mass has been correctly added to the excrement pool - excess_mass = consumed_mass * ( + # Check if the excess mass has been calculated correctly + expected_excess_mass = consumed_mass * ( 1 - herbivore_cohort_instance.functional_group.mechanical_efficiency ) - expected_excrement_carbon_increase = excess_mass / len( - [excrement_pool_instance] - ) - assert excrement_pool_instance.decomposed_carbon == pytest.approx( - initial_excrement_carbon + expected_excrement_carbon_increase - ), "Excrement pool carbon should increase by the mass of the excess carbon." + assert excess_mass == pytest.approx( + expected_excess_mass + ), "Excess mass should match the expected value based on mechanical efficiency." From af5357dcd457d7a18ac5f9bff6f309a5bc689c9f Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 30 Oct 2024 14:53:19 +0000 Subject: [PATCH 55/62] Merge of develop and multi-grid refactor. --- tests/models/animals/conftest.py | 523 ++++- tests/models/animals/test_animal_cohorts.py | 644 +++--- .../models/animals/test_animal_communities.py | 676 ------ tests/models/animals/test_animal_model.py | 1808 +++++++++++------ tests/models/animals/test_decay.py | 37 - tests/models/animals/test_plant_resources.py | 21 +- .../models/animal/animal_cohorts.py | 657 ++++-- .../models/animal/animal_communities.py | 453 ----- .../models/animal/animal_model.py | 661 +++++- .../models/animal/plant_resources.py | 26 +- virtual_ecosystem/models/animal/protocols.py | 7 +- .../models/animal/scaling_functions.py | 96 +- 12 files changed, 3244 insertions(+), 2365 deletions(-) delete mode 100644 tests/models/animals/test_animal_communities.py delete mode 100644 virtual_ecosystem/models/animal/animal_communities.py diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index 91fdc8986..e03710d23 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -77,9 +77,358 @@ def animal_data_for_model_instance(fixture_core_components): ) data["air_temperature"] = air_temperature + return data + + +@pytest.fixture +def animal_fixture_config(): + """Simple configuration fixture for use in tests.""" + + from virtual_ecosystem.core.config import Config + + cfg_string = """ + [core] + [core.grid] + cell_nx = 3 + cell_ny = 3 + [core.timing] + start_date = "2020-01-01" + update_interval = "2 weeks" + run_length = "50 years" + [core.data_output_options] + save_initial_state = true + save_final_state = true + out_initial_file_name = "model_at_start.nc" + out_final_file_name = "model_at_end.nc" + + [core.layers] + canopy_layers = 10 + soil_layers = [-0.5, -1.0] + above_canopy_height_offset = 2.0 + surface_layer_height = 0.1 + + [plants] + a_plant_integer = 12 + [[plants.ftypes]] + pft_name = "shrub" + max_height = 1.0 + [[plants.ftypes]] + pft_name = "broadleaf" + max_height = 50.0 + + [[animal.functional_groups]] + name = "carnivorous_bird" + taxa = "bird" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.1 + adult_mass = 1.0 + [[animal.functional_groups]] + name = "herbivorous_bird" + taxa = "bird" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_bird" + excretion_type = "uricotelic" + birth_mass = 0.05 + adult_mass = 0.5 + [[animal.functional_groups]] + name = "carnivorous_mammal" + taxa = "mammal" + diet = "carnivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 4.0 + adult_mass = 40.0 + [[animal.functional_groups]] + name = "herbivorous_mammal" + taxa = "mammal" + diet = "herbivore" + metabolic_type = "endothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_mammal" + excretion_type = "ureotelic" + birth_mass = 1.0 + adult_mass = 10.0 + [[animal.functional_groups]] + name = "carnivorous_insect" + taxa = "insect" + diet = "carnivore" + metabolic_type = "ectothermic" + reproductive_type = "iteroparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "carnivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.001 + adult_mass = 0.01 + [[animal.functional_groups]] + name = "herbivorous_insect" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "direct" + development_status = "adult" + offspring_functional_group = "herbivorous_insect" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "butterfly" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "semelparous" + development_type = "indirect" + development_status = "adult" + offspring_functional_group = "caterpillar" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + [[animal.functional_groups]] + name = "caterpillar" + taxa = "insect" + diet = "herbivore" + metabolic_type = "ectothermic" + reproductive_type = "nonreproductive" + development_type = "indirect" + development_status = "larval" + offspring_functional_group = "butterfly" + excretion_type = "uricotelic" + birth_mass = 0.0005 + adult_mass = 0.005 + + [hydrology] + """ + + return Config(cfg_strings=cfg_string) + + +@pytest.fixture +def animal_fixture_core_components(animal_fixture_config): + """A CoreComponents instance for use in testing.""" + from virtual_ecosystem.core.core_components import CoreComponents + + core_components = CoreComponents(animal_fixture_config) + + # Setup three filled canopy layers + canopy_array = np.full( + (core_components.layer_structure.n_canopy_layers, core_components.grid.n_cells), + np.nan, + ) + canopy_array[np.array([0, 1, 2])] = 1.0 + core_components.layer_structure.set_filled_canopy(canopy_array) + + return core_components + + +@pytest.fixture +def dummy_animal_data(animal_fixture_core_components): + """Creates a dummy climate data object for use in tests.""" + + from virtual_ecosystem.core.data import Data + + # Setup the data object with nine cells. + data = Data(animal_fixture_core_components.grid) + + # Shorten syntax + lyr_str = animal_fixture_core_components.layer_structure + from_template = lyr_str.from_template + + # Reference data with a time series + ref_values = { + "air_temperature_ref": 30.0, + "wind_speed_ref": 1.0, + "relative_humidity_ref": 90.0, + "vapour_pressure_deficit_ref": 0.14, + "vapour_pressure_ref": 0.14, + "atmospheric_pressure_ref": 96.0, + "atmospheric_co2_ref": 400.0, + "precipitation": 200.0, + "topofcanopy_radiation": 100.0, + } + + for var, value in ref_values.items(): + data[var] = DataArray( + np.full((9, 3), value), # Update to 9 grid cells + dims=["cell_id", "time_index"], + ) + + # Spatially varying but not vertically structured + spatially_variable = { + "shortwave_radiation_surface": [ + 100, + 10, + 0, + 0, + 50, + 30, + 20, + 15, + 5, + ], # Updated to 9 values + "sensible_heat_flux_topofcanopy": [ + 100, + 50, + 10, + 10, + 40, + 20, + 15, + 12, + 6, + ], # Updated + "friction_velocity": [12, 5, 2, 2, 7, 4, 3, 2.5, 1.5], # Updated + "soil_evaporation": [ + 0.001, + 0.01, + 0.1, + 0.1, + 0.05, + 0.03, + 0.02, + 0.015, + 0.008, + ], # Updated + "surface_runoff_accumulated": [0, 10, 300, 300, 100, 50, 20, 15, 5], # Updated + "subsurface_flow_accumulated": [10, 10, 30, 30, 20, 15, 12, 10, 8], # Updated + "elevation": [200, 100, 10, 10, 80, 60, 40, 30, 15], # Updated + } + for var, vals in spatially_variable.items(): + data[var] = DataArray(vals, dims=["cell_id"]) + + # Spatially constant and not vertically structured + spatially_constant = { + "sensible_heat_flux_soil": 1, + "latent_heat_flux_soil": 1, + "zero_displacement_height": 20.0, + "diabatic_correction_heat_above": 0.1, + "diabatic_correction_heat_canopy": 1.0, + "diabatic_correction_momentum_above": 0.1, + "diabatic_correction_momentum_canopy": 1.0, + "mean_mixing_length": 1.3, + "aerodynamic_resistance_surface": 12.5, + "mean_annual_temperature": 20.0, + } + for var, val in spatially_constant.items(): + data[var] = DataArray( + np.repeat(val, 9), dims=["cell_id"] + ) # Update to 9 grid cells + + # Structural variables - assign values to vertical layer indices across grid id + data["leaf_area_index"] = from_template() + data["leaf_area_index"][lyr_str.index_filled_canopy] = 1.0 + + data["canopy_absorption"] = from_template() + data["canopy_absorption"][lyr_str.index_filled_canopy] = 1.0 + + data["layer_heights"] = from_template() + data["layer_heights"][lyr_str.index_filled_atmosphere] = np.array( + [32.0, 30.0, 20.0, 10.0, lyr_str.surface_layer_height] + )[:, None] + + data["layer_heights"][lyr_str.index_all_soil] = lyr_str.soil_layer_depths[:, None] + + # Microclimate and energy balance + # - Vertically structured + data["wind_speed"] = from_template() + data["wind_speed"][lyr_str.index_filled_atmosphere] = 0.1 + + data["atmospheric_pressure"] = from_template() + data["atmospheric_pressure"][lyr_str.index_filled_atmosphere] = 96.0 + + data["air_temperature"] = from_template() + data["air_temperature"][lyr_str.index_filled_atmosphere] = np.array( + [30.0, 29.844995, 28.87117, 27.206405, 16.145945] + )[:, None] + + data["soil_temperature"] = from_template() + data["soil_temperature"][lyr_str.index_all_soil] = 20.0 + + data["relative_humidity"] = from_template() + data["relative_humidity"][lyr_str.index_filled_atmosphere] = np.array( + [90.0, 90.341644, 92.488034, 96.157312, 100] + )[:, None] + + data["absorbed_radiation"] = from_template() + data["absorbed_radiation"][lyr_str.index_filled_canopy] = 10.0 + + flux_index = np.logical_or(lyr_str.index_above, lyr_str.index_flux_layers) + + data["sensible_heat_flux"] = from_template() + data["sensible_heat_flux"][flux_index] = 0.0 + + data["latent_heat_flux"] = from_template() + data["latent_heat_flux"][flux_index] = 0.0 + + data["molar_density_air"] = from_template() + data["molar_density_air"][lyr_str.index_filled_atmosphere] = 38.0 + + data["specific_heat_air"] = from_template() + data["specific_heat_air"][lyr_str.index_filled_atmosphere] = 29.0 + + data["attenuation_coefficient"] = from_template() + data["attenuation_coefficient"][lyr_str.index_filled_atmosphere] = np.array( + [13.0, 13.0, 13.0, 13.0, 2.0] + )[:, None] + + data["relative_turbulence_intensity"] = from_template() + data["relative_turbulence_intensity"][lyr_str.index_filled_atmosphere] = np.array( + [17.64, 16.56, 11.16, 5.76, 0.414] + )[:, None] + + data["latent_heat_vapourisation"] = from_template() + data["latent_heat_vapourisation"][lyr_str.index_filled_atmosphere] = 2254.0 + + data["canopy_temperature"] = from_template() + data["canopy_temperature"][lyr_str.index_filled_canopy] = 25.0 + + data["leaf_air_heat_conductivity"] = from_template() + data["leaf_air_heat_conductivity"][lyr_str.index_filled_canopy] = 0.13 + + data["leaf_vapour_conductivity"] = from_template() + data["leaf_vapour_conductivity"][lyr_str.index_filled_canopy] = 0.2 + + data["conductivity_from_ref_height"] = from_template() + data["conductivity_from_ref_height"][ + np.logical_or(lyr_str.index_filled_canopy, lyr_str.index_surface) + ] = 3.0 + + data["stomatal_conductance"] = from_template() + data["stomatal_conductance"][lyr_str.index_filled_canopy] = 15.0 + + # Hydrology + data["evapotranspiration"] = from_template() + data["evapotranspiration"][lyr_str.index_filled_canopy] = 20.0 + + data["soil_moisture"] = from_template() + data["soil_moisture"][lyr_str.index_all_soil] = np.array([5.0, 500.0])[:, None] + + data["groundwater_storage"] = DataArray( + np.full((2, 9), 450.0), + dims=("groundwater_layers", "cell_id"), + ) + # Adding in litter variables as these are also needed now - litter_pools = DataArray(np.full(grid.n_cells, fill_value=1.5), dims="cell_id") - litter_ratios = DataArray(np.full(grid.n_cells, fill_value=25.5), dims="cell_id") + litter_pools = DataArray(np.full(data.grid.n_cells, fill_value=1.5), dims="cell_id") + litter_ratios = DataArray( + np.full(data.grid.n_cells, fill_value=25.5), dims="cell_id" + ) data["litter_pool_above_metabolic"] = litter_pools data["litter_pool_above_structural"] = litter_pools data["litter_pool_woody"] = litter_pools @@ -96,11 +445,21 @@ def animal_data_for_model_instance(fixture_core_components): data["c_p_ratio_below_metabolic"] = litter_ratios data["c_p_ratio_below_structural"] = litter_ratios + # Initialize total_animal_respiration with zeros for each cell + total_animal_respiration = np.zeros( + len(animal_fixture_core_components.grid.cell_id) + ) + data["total_animal_respiration"] = DataArray( + total_animal_respiration, + dims=["cell_id"], + coords={"cell_id": animal_fixture_core_components.grid.cell_id}, + ) + return data @pytest.fixture -def animal_data_for_community_instance(fixture_core_components): +def animal_data_for_cohorts_instance(fixture_core_components): """Fixture returning a combination of plant and air temperature data.""" from virtual_ecosystem.core.data import Data @@ -172,7 +531,7 @@ def functional_group_list_instance(shared_datadir, constants_instance): @pytest.fixture def animal_model_instance( - data_instance, + dummy_animal_data, fixture_core_components, functional_group_list_instance, constants_instance, @@ -182,7 +541,7 @@ def animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel return AnimalModel( - data=data_instance, + data=dummy_animal_data, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, @@ -190,27 +549,40 @@ def animal_model_instance( @pytest.fixture -def animal_community_instance( - functional_group_list_instance, - animal_model_instance, - animal_data_for_community_instance, +def herbivore_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[3] + + +@pytest.fixture +def herbivore_cohort_instance( + herbivore_functional_group_instance, + animal_data_for_cohorts_instance, constants_instance, ): - """Fixture for an animal community used in tests.""" - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity + """Fixture for an animal cohort used in tests.""" + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - return AnimalCommunity( - functional_groups=functional_group_list_instance, - data=animal_data_for_community_instance, - community_key=4, - neighbouring_keys=[1, 3, 5, 7], - get_destination=animal_model_instance.get_community_by_key, - constants=constants_instance, + return AnimalCohort( + herbivore_functional_group_instance, + 10000.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) @pytest.fixture -def herbivore_functional_group_instance(shared_datadir, constants_instance): +def predator_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, @@ -219,16 +591,26 @@ def herbivore_functional_group_instance(shared_datadir, constants_instance): file = shared_datadir / "example_functional_group_import.csv" fg_list = import_functional_groups(file, constants_instance) - return fg_list[3] + return fg_list[2] @pytest.fixture -def herbivore_cohort_instance(herbivore_functional_group_instance, constants_instance): +def predator_cohort_instance( + predator_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( - herbivore_functional_group_instance, 10000.0, 1, 10, constants_instance + predator_functional_group_instance, # functional group + 10000.0, # mass + 1, # age + 10, # individuals + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) @@ -247,13 +629,21 @@ def caterpillar_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture def caterpillar_cohort_instance( - caterpillar_functional_group_instance, constants_instance + caterpillar_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( - caterpillar_functional_group_instance, 1.0, 1, 100, constants_instance + caterpillar_functional_group_instance, + 1.0, + 1, + 100, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) @@ -271,12 +661,22 @@ def butterfly_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def butterfly_cohort_instance(butterfly_functional_group_instance, constants_instance): +def butterfly_cohort_instance( + butterfly_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( - butterfly_functional_group_instance, 1.0, 1, 100, constants_instance + butterfly_functional_group_instance, + 1.0, + 1, + 100, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) @@ -286,11 +686,11 @@ def excrement_pool_instance(): from virtual_ecosystem.models.animal.decay import ExcrementPool return ExcrementPool( - scavengeable_carbon=1e-1, + scavengeable_carbon=0.0, decomposed_carbon=0.0, - scavengeable_nitrogen=1e-2, + scavengeable_nitrogen=0.0, decomposed_nitrogen=0.0, - scavengeable_phosphorus=1e-4, + scavengeable_phosphorus=0.0, decomposed_phosphorus=0.0, ) @@ -319,18 +719,62 @@ def plant_list_instance(plant_data_instance, constants_instance): @pytest.fixture -def animal_list_instance(herbivore_functional_group_instance, constants_instance): +def animal_list_instance( + herbivore_functional_group_instance, + animal_data_for_cohorts_instance, + constants_instance, +): """Fixture providing a list of animal cohorts.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort return [ AnimalCohort( - herbivore_functional_group_instance, 10000.0, 1, 10, constants_instance + herbivore_functional_group_instance, + 10000.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) for idx in range(3) ] +@pytest.fixture +def carcass_pool_instance(): + """Fixture for a carcass pool used in tests.""" + from virtual_ecosystem.models.animal.decay import CarcassPool + + return CarcassPool( + scavengeable_carbon=0.0, + decomposed_carbon=0.0, + scavengeable_nitrogen=0.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=0.0, + decomposed_phosphorus=0.0, + ) + + +@pytest.fixture +def carcass_pools_instance(): + """Fixture for carcass pools used in tests.""" + from virtual_ecosystem.models.animal.decay import CarcassPool + + return { + 1: [ + CarcassPool( + scavengeable_carbon=500.0, + decomposed_carbon=0.0, + scavengeable_nitrogen=100.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=50.0, + decomposed_phosphorus=0.0, + ) + ] + } + + @pytest.fixture def litter_data_instance(fixture_core_components): """Creates a dummy litter data for use in tests.""" @@ -379,8 +823,19 @@ def litter_pool_instance(litter_data_instance): @pytest.fixture -def herbivory_waste_instance(): - """Fixture for a herbivory waste class to use in tests.""" +def herbivory_waste_pool_instance(): + """Fixture for a herbivory waste pool class to be used in tests.""" from virtual_ecosystem.models.animal.decay import HerbivoryWaste - return HerbivoryWaste(plant_matter_type="leaf") + # Create an instance of HerbivoryWaste with the valid plant_matter_type + herbivory_waste = HerbivoryWaste(plant_matter_type="leaf") + + # Manually set the additional attributes + herbivory_waste.mass_current = 0.5 # Initial mass in kg + herbivory_waste.c_n_ratio = 20.0 # Carbon to Nitrogen ratio [unitless] + herbivory_waste.c_p_ratio = 150.0 # Carbon to Phosphorus ratio [unitless] + herbivory_waste.lignin_proportion = ( + 0.25 # Proportion of lignin in the mass [unitless] + ) + + return herbivory_waste diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index a34211172..5fcec097e 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -4,29 +4,6 @@ from numpy import isclose, timedelta64 -@pytest.fixture -def predator_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[2] - - -@pytest.fixture -def predator_cohort_instance(predator_functional_group_instance, constants_instance): - """Fixture for an animal cohort used in tests.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - return AnimalCohort( - predator_functional_group_instance, 10000.0, 1, 10, constants_instance - ) - - @pytest.fixture def ectotherm_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" @@ -41,33 +18,45 @@ def ectotherm_functional_group_instance(shared_datadir, constants_instance): @pytest.fixture -def ectotherm_cohort_instance(ectotherm_functional_group_instance, constants_instance): +def ectotherm_cohort_instance( + ectotherm_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( - ectotherm_functional_group_instance, 100.0, 1, 10, constants_instance + ectotherm_functional_group_instance, + 100.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) @pytest.fixture -def prey_cohort_instance(herbivore_functional_group_instance, constants_instance): +def prey_cohort_instance( + herbivore_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( - herbivore_functional_group_instance, 100.0, 1, 10, constants_instance + herbivore_functional_group_instance, + 100.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, ) -@pytest.fixture -def carcass_pool_instance(): - """Fixture for an carcass pool used in tests.""" - from virtual_ecosystem.models.animal.decay import CarcassPool - - return CarcassPool(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) - - @pytest.mark.usefixtures("mocker") class TestAnimalCohort: """Test AnimalCohort class.""" @@ -92,6 +81,7 @@ def test_invalid_animal_cohort_initialization( age, individuals, error_type, + animal_data_for_cohorts_instance, constants_instance, ): """Test for invalid inputs during AnimalCohort initialization.""" @@ -103,6 +93,8 @@ def test_invalid_animal_cohort_initialization( mass, age, individuals, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid constants_instance, ) @@ -237,25 +229,56 @@ def test_metabolize( assert isclose(cohort_instance.mass_current, expected_final_mass, rtol=1e-9) @pytest.mark.parametrize( - argnames=[ - "cohort_type", - "excreta_mass", - "initial_pool_nitrogen", - "expected_pool_nitrogen", - "expected_pool_carbon", - "expected_pool_phosphorus", - ], - argvalues=[ - ("herbivore", 100.0, 500.0, 510.0, 5.0, 0.1), # normal case herbivore - ("herbivore", 0.0, 500.0, 500.0, 0.0, 0.0), # zero excreta mass herbivore - ("ectotherm", 50.0, 300.0, 305.0, 2.5, 0.05), # normal case ectotherm - ("ectotherm", 0.0, 300.0, 300.0, 0.0, 0.0), # zero excreta mass ectotherm + "cohort_type, excreta_mass, initial_pool_carbon, num_pools", + [ + ("herbivore", 100.0, 500.0, 1), # normal case for herbivore with one pool + ( + "herbivore", + 0.0, + 500.0, + 1, + ), # zero excreta mass for herbivore with one pool + ("ectotherm", 50.0, 300.0, 1), # normal case for ectotherm with one pool + ( + "ectotherm", + 0.0, + 300.0, + 1, + ), # zero excreta mass for ectotherm with one pool + ( + "herbivore", + 100.0, + 500.0, + 3, + ), # normal case for herbivore with multiple pools + ( + "herbivore", + 0.0, + 500.0, + 3, + ), # zero excreta mass for herbivore with multiple pools + ( + "ectotherm", + 50.0, + 300.0, + 3, + ), # normal case for ectotherm with multiple pools + ( + "ectotherm", + 0.0, + 300.0, + 3, + ), # zero excreta mass for ectotherm with multiple pools ], ids=[ - "herbivore_normal", - "herbivore_zero_excreta", - "ectotherm_normal", - "ectotherm_zero_excreta", + "herbivore_normal_one_pool", + "herbivore_zero_excreta_one_pool", + "ectotherm_normal_one_pool", + "ectotherm_zero_excreta_one_pool", + "herbivore_normal_multiple_pools", + "herbivore_zero_excreta_multiple_pools", + "ectotherm_normal_multiple_pools", + "ectotherm_zero_excreta_multiple_pools", ], ) def test_excrete( @@ -265,46 +288,69 @@ def test_excrete( ectotherm_cohort_instance, cohort_type, excreta_mass, - initial_pool_nitrogen, - expected_pool_nitrogen, - expected_pool_carbon, - expected_pool_phosphorus, + initial_pool_carbon, + num_pools, ): - """Testing excrete method for various scenarios. + """Testing excrete method for various scenarios.""" - This method is doing nothing of substance until the stoichiometry rework. - - """ + from virtual_ecosystem.models.animal.decay import ExcrementPool # Select the appropriate cohort instance - if cohort_type == "herbivore": - cohort_instance = herbivore_cohort_instance - elif cohort_type == "ectotherm": - cohort_instance = ectotherm_cohort_instance - else: - raise ValueError("Invalid cohort type provided.") - - # Mock the excrement pool - excrement_pool = mocker.Mock() - excrement_pool.decomposed_nitrogen = initial_pool_nitrogen - excrement_pool.decomposed_carbon = 0.0 - excrement_pool.decomposed_phosphorus = 0.0 + cohort_instance = ( + herbivore_cohort_instance + if cohort_type == "herbivore" + else ectotherm_cohort_instance + ) + + # Mock the excrement pools + excrement_pools = [] + for _ in range(num_pools): + excrement_pool = mocker.Mock(spec=ExcrementPool) + # Initialize the required attributes for the mock object + excrement_pool.decomposed_carbon = initial_pool_carbon + excrement_pool.scavengeable_carbon = initial_pool_carbon + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.scavengeable_nitrogen = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 + excrement_pool.scavengeable_phosphorus = 0.0 + excrement_pools.append(excrement_pool) # Call the excrete method - cohort_instance.excrete(excreta_mass, excrement_pool) + cohort_instance.excrete(excreta_mass, excrement_pools) - # Check the expected results - assert excrement_pool.decomposed_nitrogen == expected_pool_nitrogen - assert excrement_pool.decomposed_carbon == expected_pool_carbon - assert excrement_pool.decomposed_phosphorus == expected_pool_phosphorus + # Expected results calculation + excreta_mass_per_community = excreta_mass / num_pools + nitrogen_mass_per_community = ( + excreta_mass_per_community + * cohort_instance.constants.nitrogen_excreta_proportion + ) + decay_fraction = cohort_instance.decay_fraction_excrement + + # Calculate expected decomposed and scavengeable carbon + expected_decomposed_carbon = ( + initial_pool_carbon + decay_fraction * 0.5 * nitrogen_mass_per_community + ) + expected_scavengeable_carbon = ( + initial_pool_carbon + + (1 - decay_fraction) * 0.5 * nitrogen_mass_per_community + ) + + # Check assertions for carbon + for excrement_pool in excrement_pools: + assert excrement_pool.decomposed_carbon == pytest.approx( + expected_decomposed_carbon, rel=1e-3 + ) + assert excrement_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon, rel=1e-3 + ) @pytest.mark.parametrize( - "cohort_type, excreta_mass, expected_carbon_waste", + "cohort_type, excreta_mass", [ - ("herbivore", 100.0, 90.0), # normal case for herbivore - ("herbivore", 0.0, 0.0), # zero excreta mass for herbivore - ("ectotherm", 50.0, 45.0), # normal case for ectotherm - ("ectotherm", 0.0, 0.0), # zero excreta mass for ectotherm + ("herbivore", 100.0), # normal case for herbivore + ("herbivore", 0.0), # zero excreta mass for herbivore + ("ectotherm", 50.0), # normal case for ectotherm + ("ectotherm", 0.0), # zero excreta mass for ectotherm ], ids=[ "herbivore_normal", @@ -319,7 +365,6 @@ def test_respire( ectotherm_cohort_instance, cohort_type, excreta_mass, - expected_carbon_waste, ): """Testing respire method for various scenarios. @@ -335,6 +380,11 @@ def test_respire( else: raise ValueError("Invalid cohort type provided.") + # Calculate the expected carbon waste based on the cohort's constants + expected_carbon_waste = ( + excreta_mass * cohort_instance.constants.carbon_excreta_proportion + ) + # Call the respire method carbon_waste = cohort_instance.respire(excreta_mass) @@ -342,52 +392,80 @@ def test_respire( assert carbon_waste == expected_carbon_waste @pytest.mark.parametrize( - argnames=[ - "scav_initial", - "scav_final_carbon", - "scav_final_nitrogen", - "scav_final_phosphorus", - "decomp_initial", - "decomp_final_carbon", - "decomp_final_nitrogen", - "decomp_final_phosphorus", - "consumed_carbon", + "scav_initial, decomp_initial, consumed_mass, num_pools", + [ + (1000.0, 0.0, 1000.0, 1), # Single pool, waste mass consumed + (0.0, 1000.0, 1000.0, 1), # Single pool, initial decomposed + (1000.0, 0.0, 0.0, 1), # No mass consumed, single pool + (0.0, 1000.0, 0.0, 1), # No mass consumed, initial decomposed + (1000.0, 0.0, 1000.0, 3), # Test with multiple pools + ( + 0.0, + 1000.0, + 1000.0, + 3, + ), # Test with multiple pools, initial decomposed ], - argvalues=[ - pytest.param(1000.0, 1500.0, 50.0, 5.0, 0.0, 500.0, 50.0, 5.0, 1000.0), - pytest.param(0.0, 500.0, 50.0, 5.0, 1000.0, 1500.0, 50.0, 5.0, 1000.0), - pytest.param(1000.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - pytest.param(0.0, 0.0, 0.0, 0.0, 1000.0, 1000.0, 0.0, 0.0, 0.0), + ids=[ + "single_pool_scenario_1", + "single_pool_scenario_2", + "single_pool_scenario_3", + "single_pool_scenario_4", + "multiple_pools_scenario_1", + "multiple_pools_scenario_2", ], ) def test_defecate( self, + mocker, herbivore_cohort_instance, - excrement_pool_instance, scav_initial, - scav_final_carbon, - scav_final_nitrogen, - scav_final_phosphorus, decomp_initial, - decomp_final_carbon, - decomp_final_nitrogen, - decomp_final_phosphorus, - consumed_carbon, + consumed_mass, + num_pools, ): - """Testing defecate() for varying soil energy levels.""" - excrement_pool_instance.scavengeable_carbon = scav_initial - excrement_pool_instance.decomposed_carbon = decomp_initial - excrement_pool_instance.scavengeable_nitrogen = 0.0 - excrement_pool_instance.decomposed_nitrogen = 0.0 - excrement_pool_instance.scavengeable_phosphorus = 0.0 - excrement_pool_instance.decomposed_phosphorus = 0.0 - herbivore_cohort_instance.defecate(excrement_pool_instance, consumed_carbon) - assert excrement_pool_instance.scavengeable_carbon == scav_final_carbon - assert excrement_pool_instance.decomposed_carbon == decomp_final_carbon - assert excrement_pool_instance.scavengeable_nitrogen == scav_final_nitrogen - assert excrement_pool_instance.decomposed_nitrogen == decomp_final_nitrogen - assert excrement_pool_instance.scavengeable_phosphorus == scav_final_phosphorus - assert excrement_pool_instance.decomposed_phosphorus == decomp_final_phosphorus + """Testing defecate() for varying carbon mass levels and multiple pools.""" + + # Mock the excrement pools + excrement_pools = [] + for _ in range(num_pools): + excrement_pool = mocker.Mock() + excrement_pool.scavengeable_carbon = scav_initial + excrement_pool.decomposed_carbon = decomp_initial + excrement_pool.scavengeable_nitrogen = 0.0 + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.scavengeable_phosphorus = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 + excrement_pools.append(excrement_pool) + + # Call the defecate method + herbivore_cohort_instance.defecate(excrement_pools, consumed_mass) + + # Check the expected results + for excrement_pool in excrement_pools: + expected_scavengeable_carbon = ( + scav_initial + + (1 - herbivore_cohort_instance.decay_fraction_excrement) + * consumed_mass + / num_pools + * herbivore_cohort_instance.functional_group.conversion_efficiency + * herbivore_cohort_instance.individuals + ) + expected_decomposed_carbon = ( + decomp_initial + + herbivore_cohort_instance.decay_fraction_excrement + * consumed_mass + / num_pools + * herbivore_cohort_instance.functional_group.conversion_efficiency + * herbivore_cohort_instance.individuals + ) + + assert excrement_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon + ) + assert excrement_pool.decomposed_carbon == pytest.approx( + expected_decomposed_carbon + ) @pytest.mark.parametrize( "dt, initial_age, final_age", @@ -409,120 +487,172 @@ def test_increase_age(self, herbivore_cohort_instance, dt, initial_age, final_ag "number_dead", "initial_pop", "final_pop", - "initial_carcass", - "final_carcass_carbon", - "final_carcass_nitrogen", - "final_carcass_phosphorus", - "decomp_carcass_carbon", - "decomp_carcass_nitrogen", - "decomp_carcass_phosphorus", + "initial_scavengeable_carbon", + "final_scavengeable_carbon", + "decomp_carcass", + "num_pools", ], argvalues=[ - (0, 0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - (0, 1000, 1000, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - (1, 1, 0, 1.0, 8001.0, 800.0, 80.0, 2000.0, 200.0, 20.0), - (100, 200, 100, 0.0, 800000.0, 80000.0, 8000.0, 200000.0, 20000.0, 2000.0), + (0, 0, 0, 0.0, 0.0, 0.0, 1), # No deaths, empty population + (0, 1000, 1000, 0.0, 0.0, 0.0, 1), # No deaths, non-empty population + (1, 1, 0, 1.0, 8001.0, 2000.0, 1), # Single death, single pool + (100, 200, 100, 0.0, 800000.0, 200000.0, 1), # Multiple deaths, single pool + (1, 1, 0, 1.0, 2667.6667, 666.67, 3), # Single death, multiple pools + ( + 100, + 200, + 100, + 0.0, + 266666.67, + 66666.67, + 3, + ), # Multiple deaths, multiple pools + ], + ids=[ + "zero_death_empty_pop", + "zero_death_non_empty_pop", + "single_death_single_pool", + "multiple_deaths_single_pool", + "single_death_multiple_pools", + "multiple_deaths_multiple_pools", ], ) def test_die_individual( self, herbivore_cohort_instance, + carcass_pools_instance, number_dead, initial_pop, final_pop, - carcass_pool_instance, - initial_carcass, - final_carcass_carbon, - final_carcass_nitrogen, - final_carcass_phosphorus, - decomp_carcass_carbon, - decomp_carcass_nitrogen, - decomp_carcass_phosphorus, + initial_scavengeable_carbon, + final_scavengeable_carbon, + decomp_carcass, + num_pools, ): - """Testing death.""" + """Testing death and carcass mass transfer to pools.""" + + from virtual_ecosystem.models.animal.decay import CarcassPool + + # Set the initial population for the herbivore cohort herbivore_cohort_instance.individuals = initial_pop - carcass_pool_instance.scavengeable_carbon = initial_carcass - herbivore_cohort_instance.die_individual(number_dead, carcass_pool_instance) + + # Use the `carcass_pools_instance` fixture + carcass_pools = { + key: [ + CarcassPool( + scavengeable_carbon=initial_scavengeable_carbon, + decomposed_carbon=0.0, + scavengeable_nitrogen=0.0, + decomposed_nitrogen=0.0, + scavengeable_phosphorus=0.0, + decomposed_phosphorus=0.0, + ) + for _ in range(num_pools) + ] + for key in carcass_pools_instance.keys() + } + + # Call the die_individual method + herbivore_cohort_instance.die_individual(number_dead, carcass_pools[1]) + + # Check the population after death assert herbivore_cohort_instance.individuals == final_pop - assert carcass_pool_instance.scavengeable_carbon == final_carcass_carbon - assert carcass_pool_instance.decomposed_carbon == decomp_carcass_carbon - assert carcass_pool_instance.scavengeable_nitrogen == final_carcass_nitrogen - assert carcass_pool_instance.decomposed_nitrogen == decomp_carcass_nitrogen - assert carcass_pool_instance.scavengeable_phosphorus == final_carcass_phosphorus - assert carcass_pool_instance.decomposed_phosphorus == decomp_carcass_phosphorus + + # Check the expected results for carcass mass distribution + for carcass_pool in carcass_pools[1]: + expected_scavengeable_carbon = ( + initial_scavengeable_carbon + + (1 - herbivore_cohort_instance.decay_fraction_carcasses) + * (number_dead * herbivore_cohort_instance.mass_current) + / num_pools + ) + + assert carcass_pool.scavengeable_carbon == pytest.approx( + expected_scavengeable_carbon + ) @pytest.mark.parametrize( - argnames=[ - "carcass_mass", - "final_carcass_carbon", - "final_carcass_nitrogen", - "final_carcass_phosphorus", - "decomp_carcass_carbon", - "decomp_carcass_nitrogen", - "decomp_carcass_phosphorus", + "initial_individuals, potential_consumed_mass, mechanical_efficiency," + "expected_consumed_mass", + [ + (10, 100.0, 0.75, 100.0), + (5, 200.0, 0.5, 200.0), + (100, 50.0, 0.8, 50.0), + (1, 5.0, 0.9, 5.0), ], - argvalues=[ - (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - (1000.0, 800.0, 80.0, 8.0, 200.0, 20.0, 2.0), - (3000.0, 2400.0, 240.0, 24.0, 600.0, 60.0, 6.0), + ids=[ + "ten_individuals_consumed_100_mass_eff_0.75", + "five_individuals_consumed_200_mass_eff_0.5", + "hundred_individuals_consumed_50_mass_eff_0.8", + "one_individual_consumed_5_mass_eff_0.9", ], ) - def test_update_carcass_pool( + def test_get_eaten( self, + mocker, herbivore_cohort_instance, - carcass_pool_instance, - carcass_mass, - final_carcass_carbon, - final_carcass_nitrogen, - final_carcass_phosphorus, - decomp_carcass_carbon, - decomp_carcass_nitrogen, - decomp_carcass_phosphorus, + predator_cohort_instance, + initial_individuals, + potential_consumed_mass, + mechanical_efficiency, + expected_consumed_mass, ): - """Test function to update the carcass pool after predation.""" - herbivore_cohort_instance.update_carcass_pool( - carcass_mass, carcass_pool_instance + """Test the get_eaten method for accuracy in updating prey and carcass pool.""" + + from math import ceil + + # Setup initial values + herbivore_cohort_instance.individuals = initial_individuals + predator_cohort_instance.functional_group.mechanical_efficiency = ( + mechanical_efficiency ) - assert carcass_pool_instance.scavengeable_carbon == final_carcass_carbon - assert carcass_pool_instance.decomposed_carbon == decomp_carcass_carbon - assert carcass_pool_instance.scavengeable_nitrogen == final_carcass_nitrogen - assert carcass_pool_instance.decomposed_nitrogen == decomp_carcass_nitrogen - assert carcass_pool_instance.scavengeable_phosphorus == final_carcass_phosphorus - assert carcass_pool_instance.decomposed_phosphorus == decomp_carcass_phosphorus - def test_get_eaten( - self, prey_cohort_instance, predator_cohort_instance, carcass_pool_instance - ): - """Test the get_eaten method for accuracy in updating prey and carcass pool.""" - potential_consumed_mass = 100 # Set a potential consumed mass for testing - initial_individuals = prey_cohort_instance.individuals - initial_mass_current = prey_cohort_instance.mass_current - initial_carcass_scavengeable_carbon = carcass_pool_instance.scavengeable_carbon - initial_carcass_decomposed_carbon = carcass_pool_instance.decomposed_carbon + # Mock find_intersecting_carcass_pools to return a list of mock carcass pools + carcass_pool_1 = mocker.Mock() + carcass_pool_2 = mocker.Mock() + mock_find_intersecting_carcass_pools = mocker.patch.object( + herbivore_cohort_instance, + "find_intersecting_carcass_pools", + return_value=[carcass_pool_1, carcass_pool_2], + ) + + # Mock update_carcass_pool to update the carcass pools + mock_update_carcass_pool = mocker.patch.object( + herbivore_cohort_instance, "update_carcass_pool" + ) + + # Provide a mocked carcass_pools to pass to the get_eaten method + carcass_pools = {1: [carcass_pool_1, carcass_pool_2]} # Execute the get_eaten method with test parameters - actual_consumed_mass = prey_cohort_instance.get_eaten( - potential_consumed_mass, predator_cohort_instance, carcass_pool_instance + actual_consumed_mass = herbivore_cohort_instance.get_eaten( + potential_consumed_mass, predator_cohort_instance, carcass_pools ) - # Assertions to check if individuals were correctly removed and carcass pool - # updated - assert ( - prey_cohort_instance.individuals < initial_individuals - ), "Prey cohort should have fewer individuals." - assert ( - prey_cohort_instance.mass_current == initial_mass_current - ), "Prey cohort should have the same total mass." - assert ( - actual_consumed_mass <= potential_consumed_mass - ), "Actual consumed mass should be less than/equal to potential consumed mass." - assert ( - carcass_pool_instance.scavengeable_carbon - > initial_carcass_scavengeable_carbon - ), "Carcass pool's scavengeable carbon should increase." - assert ( - carcass_pool_instance.decomposed_carbon > initial_carcass_decomposed_carbon - ), "Carcass pool's decomposed carbon should increase." + # Calculate expected individuals killed + individual_mass = herbivore_cohort_instance.mass_current + max_individuals_killed = ceil(potential_consumed_mass / individual_mass) + actual_individuals_killed = min(max_individuals_killed, initial_individuals) + expected_final_individuals = initial_individuals - actual_individuals_killed + + # Assertions for if individuals were correctly removed and carcass pool updated + assert herbivore_cohort_instance.individuals == expected_final_individuals + assert actual_consumed_mass == pytest.approx(expected_consumed_mass) + + # Verify the update_carcass_pool call + carcass_mass = ( + (actual_individuals_killed * individual_mass) + - actual_consumed_mass + + (actual_consumed_mass * (1 - mechanical_efficiency)) + ) + mock_update_carcass_pool.assert_called_once_with( + carcass_mass, [carcass_pool_1, carcass_pool_2] + ) + + # Check if find_intersecting_carcass_pools was called correctly + mock_find_intersecting_carcass_pools.assert_called_once_with( + predator_cohort_instance.territory, carcass_pools + ) @pytest.mark.parametrize( "below_threshold,expected_mass_current_increase," @@ -635,6 +765,7 @@ def test_calculate_alpha( mass_current, expected_alpha, herbivore_functional_group_instance, + animal_data_for_cohorts_instance, ): """Testing for calculate alpha.""" # Assuming necessary imports and setup based on previous examples @@ -657,6 +788,8 @@ def test_calculate_alpha( mass=mass_current, age=1.0, # Example age individuals=1, # Example number of individuals + centroid_key=1, # centroid + grid=animal_data_for_cohorts_instance.grid, # grid constants=constants, ) @@ -678,7 +811,13 @@ def test_calculate_alpha( ], ) def test_calculate_potential_consumed_biomass( - self, mocker, alpha, mass_current, phi_herb_t, expected_biomass + self, + mocker, + alpha, + mass_current, + phi_herb_t, + expected_biomass, + animal_data_for_cohorts_instance, ): """Testing for calculate_potential_consumed_biomass.""" from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort @@ -686,7 +825,8 @@ def test_calculate_potential_consumed_biomass( from virtual_ecosystem.models.animal.protocols import Resource # Mock the target plant - target_plant = mocker.MagicMock(spec=Resource, mass_current=mass_current) + target_plant = mocker.MagicMock(spec=Resource) + target_plant.mass_current = mass_current # Mock k_i_k to return the expected_biomass k_i_k_mock = mocker.patch( @@ -697,7 +837,12 @@ def test_calculate_potential_consumed_biomass( # Setup functional group mock to provide phi_herb_t functional_group_mock = mocker.MagicMock() functional_group_mock.diet = DietType("herbivore") - functional_group_mock.constants.phi_herb_t = phi_herb_t + constants_mock = mocker.MagicMock() + constants_mock.phi_herb_t = phi_herb_t + functional_group_mock.constants = constants_mock + + # Mock the adult_mass attribute + functional_group_mock.adult_mass = 50.0 # Example mass, adjust as needed # Initialize the AnimalCohort instance with mocked functional group cohort_instance = AnimalCohort( @@ -705,6 +850,8 @@ def test_calculate_potential_consumed_biomass( mass=100.0, # Arbitrary value since mass is not directly used in this test age=1.0, # Arbitrary value individuals=1, # Arbitrary value + centroid_key=1, # Use centroid_key instead of centroid + grid=animal_data_for_cohorts_instance.grid, # grid constants=mocker.MagicMock(), ) @@ -719,11 +866,11 @@ def test_calculate_potential_consumed_biomass( f"phi_herb_t={phi_herb_t}" ) - # verify that k_i_k was called with the correct parameters + # Verify that k_i_k was called with the correct parameters A_cell = 1.0 k_i_k_mock.assert_called_once_with(alpha, phi_herb_t, mass_current, A_cell) - def calculate_total_handling_time_for_herbivory( + def test_calculate_total_handling_time_for_herbivory( self, mocker, herbivore_cohort_instance, plant_list_instance ): """Test aggregation of handling times across all available plant resources.""" @@ -1051,7 +1198,7 @@ def test_delta_mass_predation( predator_cohort_instance, animal_list_instance, excrement_pool_instance, - carcass_pool_instance, + carcass_pool_instance, # Add carcass_pool_instance consumed_mass, expected_total_consumed_mass, ): @@ -1077,16 +1224,13 @@ def test_delta_mass_predation( # Mock predator_cohort_instance.defecate to verify its call mock_defecate = mocker.patch.object(predator_cohort_instance, "defecate") + # Add carcass_pool_instance to the test call total_consumed_mass = predator_cohort_instance.delta_mass_predation( animal_list_instance, excrement_pool_instance, carcass_pool_instance ) - # Check if the total consumed mass matches the expected value - assert ( - total_consumed_mass == expected_total_consumed_mass - ), "Total consumed mass should match expected value." - - # Ensure defecate was called with the correct total consumed mass + # Assertions + assert total_consumed_mass == expected_total_consumed_mass mock_defecate.assert_called_once_with( excrement_pool_instance, total_consumed_mass ) @@ -1097,7 +1241,7 @@ def test_delta_mass_herbivory( herbivore_cohort_instance, plant_list_instance, excrement_pool_instance, - herbivory_waste_instance, + herbivory_waste_pool_instance, ): """Test mass assimilation calculation from herbivory.""" @@ -1105,21 +1249,30 @@ def test_delta_mass_herbivory( mock_calculate_consumed_mass_herbivory = mocker.patch.object( herbivore_cohort_instance, "calculate_consumed_mass_herbivory", - side_effect=lambda plant_list, plant: 10.0, - # Assume 10.0 kg mass consumed from each plant for simplicity + side_effect=lambda plant_list, + plant: 10.0, # Assume 10.0 kg mass consumed from each plant for simplicity ) - # Mock the PlantResources.get_eaten method + # Mock the PlantResources.get_eaten method to match its original signature mock_get_eaten = mocker.patch( "virtual_ecosystem.models.animal.plant_resources.PlantResources.get_eaten", side_effect=lambda consumed_mass, herbivore: ( consumed_mass, - 0.01 * consumed_mass, - ), + 0.0, + ), # Return consumed_mass and 0.0 as excess_mass ) + # Ensure herbivory_waste_pools includes entries for all plant cell IDs + herbivory_waste_pools = { + plant.cell_id: herbivory_waste_pool_instance + for plant in plant_list_instance + } + + # Call the delta_mass_herbivory method delta_mass = herbivore_cohort_instance.delta_mass_herbivory( - plant_list_instance, excrement_pool_instance, herbivory_waste_instance + plant_list_instance, + [excrement_pool_instance], + herbivory_waste_pools, ) # Ensure calculate_consumed_mass_herbivory and get_eaten were called correctly @@ -1130,22 +1283,11 @@ def test_delta_mass_herbivory( # Calculate the expected total consumed mass based on the number of plants expected_delta_mass = 10.0 * len(plant_list_instance) - expected_litter_addition = 0.1 * len(plant_list_instance) - # Assert the calculated delta_mass_herb matches the expected value + # Assert the calculated delta_mass matches the expected value assert delta_mass == pytest.approx( expected_delta_mass ), "Calculated change in mass due to herbivory did not match expected value." - assert herbivory_waste_instance.mass_current == pytest.approx( - expected_litter_addition - ), "Addition to leaf litter due to herbivory did not match expected value." - # Check that excrement pool has actually been added to - assert excrement_pool_instance.decomposed_carbon == pytest.approx( - 0.5 - * expected_delta_mass - * herbivore_cohort_instance.functional_group.conversion_efficiency - * herbivore_cohort_instance.individuals - ), "Carbon hasn't been added to the excrement pool." def test_forage_cohort( self, @@ -1155,8 +1297,8 @@ def test_forage_cohort( plant_list_instance, animal_list_instance, excrement_pool_instance, - carcass_pool_instance, - herbivory_waste_instance, + carcass_pools_instance, + herbivory_waste_pool_instance, ): """Test foraging behavior for different diet types.""" @@ -1170,16 +1312,22 @@ def test_forage_cohort( mock_eat_herbivore = mocker.patch.object(herbivore_cohort_instance, "eat") mock_eat_predator = mocker.patch.object(predator_cohort_instance, "eat") + # Ensure herbivory_waste_pools includes entries for all plant cell IDs + herbivory_waste_pools = { + plant.cell_id: herbivory_waste_pool_instance + for plant in plant_list_instance + } + # Test herbivore diet herbivore_cohort_instance.forage_cohort( plant_list_instance, [], - excrement_pool=excrement_pool_instance, - carcass_pool=carcass_pool_instance, - herbivory_waste_pool=herbivory_waste_instance, + excrement_pool_instance, + carcass_pools_instance, + herbivory_waste_pools, ) mock_delta_mass_herbivory.assert_called_once_with( - plant_list_instance, excrement_pool_instance, herbivory_waste_instance + plant_list_instance, excrement_pool_instance, herbivory_waste_pools ) mock_eat_herbivore.assert_called_once_with(100) @@ -1188,11 +1336,11 @@ def test_forage_cohort( [], animal_list_instance, excrement_pool_instance, - carcass_pool_instance, - herbivory_waste_pool=herbivory_waste_instance, + carcass_pools_instance, + {}, ) mock_delta_mass_predation.assert_called_once_with( - animal_list_instance, excrement_pool_instance, carcass_pool_instance + animal_list_instance, excrement_pool_instance, carcass_pools_instance ) mock_eat_predator.assert_called_once_with(200) @@ -1338,7 +1486,7 @@ def test_inflict_non_predation_mortality( print(f"Initial individuals: {cohort.individuals}") # Run the method - cohort.inflict_non_predation_mortality(dt, carcass_pool_instance) + cohort.inflict_non_predation_mortality(dt, [carcass_pool_instance]) # Calculate expected number of deaths inside the test u_bg_value = sf.background_mortality(u_bg) diff --git a/tests/models/animals/test_animal_communities.py b/tests/models/animals/test_animal_communities.py deleted file mode 100644 index 61058cb3c..000000000 --- a/tests/models/animals/test_animal_communities.py +++ /dev/null @@ -1,676 +0,0 @@ -"""Test module for animal_communities.py.""" - -from math import ceil - -import pytest -from pytest_mock import MockerFixture - - -@pytest.fixture -def animal_community_destination_instance( - functional_group_list_instance, - animal_model_instance, - animal_data_for_community_instance, - constants_instance, -): - """Fixture for an animal community used in tests.""" - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity - - return AnimalCommunity( - functional_groups=functional_group_list_instance, - data=animal_data_for_community_instance, - community_key=4, - neighbouring_keys=[1, 3, 5, 7], - get_destination=animal_model_instance.get_community_by_key, - constants=constants_instance, - ) - - -@pytest.fixture -def 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[3] - - -@pytest.fixture -def animal_cohort_instance(functional_group_instance, constants_instance): - """Fixture for an animal cohort used in tests.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - return AnimalCohort( - functional_group_instance, - functional_group_instance.adult_mass, - 1.0, - 10, - constants_instance, - ) - - -class TestAnimalCommunity: - """Test AnimalCommunity class.""" - - def test_initialization(self, animal_community_instance): - """Testing initialization of derived parameters for animal cohorts.""" - assert list(animal_community_instance.animal_cohorts.keys()) == [ - "carnivorous_bird", - "herbivorous_bird", - "carnivorous_mammal", - "herbivorous_mammal", - "carnivorous_insect_iteroparous", - "herbivorous_insect_iteroparous", - "carnivorous_insect_semelparous", - "herbivorous_insect_semelparous", - "butterfly", - "caterpillar", - ] - - def test_all_animal_cohorts_property( - self, animal_community_instance, animal_cohort_instance - ): - """Test the all_animal_cohorts property.""" - from collections.abc import Iterable - - # Add an animal cohort to the community - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - - # Check if the added cohort is in the all_animal_cohorts property - assert animal_cohort_instance in animal_community_instance.all_animal_cohorts - # Check the type of all_animal_cohorts - assert isinstance(animal_community_instance.all_animal_cohorts, Iterable) - - def test_populate_community(self, animal_community_instance): - """Testing populate_community.""" - animal_community_instance.populate_community() - for cohorts in animal_community_instance.animal_cohorts.values(): - assert len(cohorts) == 1 # since it should have populated one of each - - def test_migrate( - self, - animal_cohort_instance, - animal_community_instance, - animal_community_destination_instance, - ): - """Testing migrate.""" - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - animal_community_instance.migrate( - animal_community_instance.animal_cohorts["herbivorous_mammal"][0], - animal_community_destination_instance, - ) - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - assert ( - animal_cohort_instance - in animal_community_destination_instance.animal_cohorts[ - "herbivorous_mammal" - ] - ) - - @pytest.mark.parametrize( - "mass_ratio, age, probability_output, should_migrate", - [ - (0.5, 5.0, False, True), # Starving non-juvenile, should migrate - ( - 1.0, - 0.0, - False, - False, - ), # Well-fed juvenile, low probability, should not migrate - ( - 1.0, - 0.0, - True, - True, - ), # Well-fed juvenile, high probability, should migrate - ( - 0.5, - 0.0, - True, - True, - ), # Starving juvenile, high probability, should migrate - ( - 0.5, - 0.0, - False, - True, - ), # Starving juvenile, low probability, should migrate due to starvation - (1.0, 5.0, False, False), # Well-fed non-juvenile, should not migrate - ], - ids=[ - "starving_non_juvenile", - "well_fed_juvenile_low_prob", - "well_fed_juvenile_high_prob", - "starving_juvenile_high_prob", - "starving_juvenile_low_prob", - "well_fed_non_juvenile", - ], - ) - def test_migrate_community( - self, - mocker, - animal_community_instance, - animal_community_destination_instance, - animal_cohort_instance, - mass_ratio, - age, - probability_output, - should_migrate, - ): - """Test migration of cohorts for both starving and juvenile conditions.""" - - cohort = animal_cohort_instance - cohort.age = age - cohort.mass_current = cohort.functional_group.adult_mass * mass_ratio - - # Mock the get_destination callable to return a specific community. - mocker.patch.object( - animal_community_instance, - "get_destination", - return_value=animal_community_destination_instance, - ) - - # Append cohort to the source community - animal_community_instance.animal_cohorts["herbivorous_mammal"].append(cohort) - - # Mock `migrate_juvenile_probability` to control juvenile migration logic - mocker.patch.object( - cohort, "migrate_juvenile_probability", return_value=probability_output - ) - - # Perform the migration - animal_community_instance.migrate_community() - - # Check migration outcome based on expected results - if should_migrate: - assert ( - cohort - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - assert ( - cohort - in animal_community_destination_instance.animal_cohorts[ - "herbivorous_mammal" - ] - ) - else: - assert ( - cohort in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - - def test_remove_dead_cohort( - self, animal_cohort_instance, animal_community_instance - ): - """Testing remove_dead_cohort.""" - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - assert animal_cohort_instance.is_alive - animal_community_instance.remove_dead_cohort(animal_cohort_instance) - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - animal_cohort_instance.is_alive = False - assert not animal_cohort_instance.is_alive - animal_community_instance.remove_dead_cohort(animal_cohort_instance) - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts["herbivorous_mammal"] - ) - - @pytest.mark.parametrize( - "reproductive_type, initial_mass, expected_offspring", - [ - pytest.param("iteroparous", 10, 1, id="iteroparous_survival"), - pytest.param("semelparous", 10, 1, id="semelparous_death"), - ], - ) - def test_birth( - self, - reproductive_type, - initial_mass, - expected_offspring, - animal_community_instance, - animal_cohort_instance, - ): - """Test the birth method in AnimalCommunity under various conditions.""" - - # Setup initial conditions - parent_cohort_name = animal_cohort_instance.name - animal_cohort_instance.functional_group.reproductive_type = reproductive_type - animal_cohort_instance.functional_group.birth_mass = 2 - animal_cohort_instance.mass_current = initial_mass - animal_cohort_instance.individuals = 10 - - # Prepare the community - animal_community_instance.animal_cohorts[parent_cohort_name] = [ - animal_cohort_instance - ] - - number_cohorts = len( - animal_community_instance.animal_cohorts[parent_cohort_name] - ) - - animal_community_instance.birth(animal_cohort_instance) - - # Assertions - # 1. Check for changes in the parent cohort based on reproductive type - if reproductive_type == "semelparous": - # The parent should be removed if it dies - assert ( - animal_cohort_instance - not in animal_community_instance.animal_cohorts[parent_cohort_name] - ) - else: - # Reproductive mass should be reset - assert animal_cohort_instance.reproductive_mass == 0 - # The parent should still be present in the community - assert ( - animal_cohort_instance - in animal_community_instance.animal_cohorts[parent_cohort_name] - ) - - # 2. Check that the offspring were added if reproduction occurred - - if expected_offspring and reproductive_type == "semelparous": - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts - ) - elif expected_offspring and reproductive_type == "iteroparous": - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts + 1 - ) - else: - assert ( - len(animal_community_instance.animal_cohorts[parent_cohort_name]) - == number_cohorts - ) - - def test_birth_community(self, animal_community_instance, constants_instance): - """Test the thresholding behavior of birth_community.""" - - from itertools import chain - - # Preparation: populate the community - animal_community_instance.populate_community() - - # Choose a cohort to track - all_cohorts = list( - chain.from_iterable(animal_community_instance.animal_cohorts.values()) - ) - initial_cohort = all_cohorts[0] - - # Set mass to just below the threshold - threshold_mass = ( - initial_cohort.functional_group.adult_mass - * constants_instance.birth_mass_threshold - - initial_cohort.functional_group.adult_mass - ) - - initial_cohort.reproductive_mass = threshold_mass - 0.1 - initial_count_below_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - - # Execution: apply birth to the community - animal_community_instance.birth_community() - - # Assertion: check if the cohort count remains unchanged - new_count_below_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - assert new_count_below_threshold == initial_count_below_threshold - - # Set mass to just above the threshold - initial_cohort.reproductive_mass = threshold_mass + 0.1 - initial_count_above_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - - # Execution: apply birth to the community again - animal_community_instance.birth_community() - - # Assertion: check if the cohort count increased by 1 for the above case - new_count_above_threshold = len( - animal_community_instance.animal_cohorts[initial_cohort.name] - ) - assert new_count_above_threshold == initial_count_above_threshold + 1 - - def test_forage_community( - self, animal_cohort_instance, animal_community_instance, mocker - ): - """Testing forage_community.""" - import unittest - from copy import deepcopy - - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity - - # Prepare data - animal_cohort_instance_2 = deepcopy(animal_cohort_instance) - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance - ) - animal_community_instance.animal_cohorts["herbivorous_mammal"].append( - animal_cohort_instance_2 - ) - - # Mock methods - mock_forage_cohort = mocker.patch.object(AnimalCohort, "forage_cohort") - - mock_collect_prey = mocker.patch.object( - AnimalCommunity, "collect_prey", return_value=mocker.MagicMock() - ) - - # Execute method - animal_community_instance.forage_community() - - # Check if the forage_cohort and collect_prey methods have been called for each - # cohort - assert mock_forage_cohort.call_count == 2 - assert mock_collect_prey.call_count == 2 - - # Check if the forage_cohort and collect_prey methods were called correctly - for call in mock_forage_cohort.call_args_list: - _, kwargs = call - assert isinstance(kwargs.get("plant_list", None), list) - assert isinstance(kwargs.get("animal_list", None), unittest.mock.MagicMock) - assert isinstance( - kwargs.get("carcass_pool", None), - type(animal_community_instance.carcass_pool), - ) - - def test_collect_prey_finds_eligible_prey( - self, - animal_cohort_instance, - animal_community_instance, - functional_group_instance, - ): - """Testing collect_prey with eligible prey items.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - prey_cohort = AnimalCohort(functional_group_instance, 5000.0, 1, 10) - animal_community_instance.animal_cohorts[functional_group_instance.name].append( - prey_cohort - ) - - animal_cohort_instance.prey_groups = { - functional_group_instance.name: (0, 10000) - } - - collected_prey = animal_community_instance.collect_prey(animal_cohort_instance) - - assert prey_cohort in collected_prey - - def test_collect_prey_filters_out_ineligible_prey( - self, - animal_cohort_instance, - animal_community_instance, - functional_group_instance, - ): - """Testing collect_prey with no eligible prey items.""" - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - prey_cohort = AnimalCohort(functional_group_instance, 20000.0, 1, 10) - animal_community_instance.animal_cohorts[functional_group_instance.name].append( - prey_cohort - ) - - animal_cohort_instance.prey_groups = { - functional_group_instance.name: (0, 10000) - } - - collected_prey = animal_community_instance.collect_prey(animal_cohort_instance) - - assert prey_cohort not in collected_prey - - def test_increase_age_community(self, animal_community_instance): - """Testing increase_age_community.""" - from itertools import chain - - from numpy import timedelta64 - - animal_community_instance.populate_community() - - initial_age = next( - iter(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ).age - animal_community_instance.increase_age_community(timedelta64(5, "D")) - new_age = next( - iter(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ).age - assert new_age == initial_age + 5 - - def test_metabolize_community( - self, animal_community_instance, mocker: MockerFixture - ): - """Testing metabolize_community.""" - from itertools import chain - - from numpy import timedelta64 - - from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort - - # Mocking the AnimalCohort methods - mock_metabolize = mocker.patch.object( - AnimalCohort, "metabolize", return_value=100.0 - ) - mock_respire = mocker.patch.object(AnimalCohort, "respire", return_value=90.0) - mock_excrete = mocker.patch.object(AnimalCohort, "excrete") - - # Initial value of total animal respiration - initial_respiration = ( - animal_community_instance.data["total_animal_respiration"] - .loc[{"cell_id": animal_community_instance.community_key}] - .item() - ) - - # Call the metabolize_community method - animal_community_instance.metabolize_community(25.0, timedelta64(5, "D")) - - # Calculate expected respiration after the method call - num_cohorts = len( - list(chain.from_iterable(animal_community_instance.animal_cohorts.values())) - ) - expected_total_respiration = initial_respiration + num_cohorts * 90.0 - - # Check that metabolize was called the correct number of times - assert mock_metabolize.call_count == num_cohorts - - # Check that respire was called the correct number of times - assert mock_respire.call_count == num_cohorts - - # Check that excrete was called the correct number of times - assert mock_excrete.call_count == num_cohorts - - # Verify that total_animal_respiration was updated correctly - updated_respiration = ( - animal_community_instance.data["total_animal_respiration"] - .loc[{"cell_id": animal_community_instance.community_key}] - .item() - ) - assert updated_respiration == expected_total_respiration - - @pytest.mark.parametrize( - "days", - [ - pytest.param(1, id="one_day"), - pytest.param(5, id="five_days"), - pytest.param(10, id="ten_days"), - ], - ) - def test_inflict_non_predation_mortality_community( - self, mocker, animal_community_instance, days - ): - """Testing natural mortality infliction for the entire community.""" - from numpy import timedelta64 - - dt = timedelta64(days, "D") - - animal_community_instance.populate_community() - - # Mock the inflict_non_predation_mortality method - mock_mortality = mocker.patch( - "virtual_ecosystem.models.animal.animal_cohorts.AnimalCohort." - "inflict_non_predation_mortality" - ) - - # Call the community method to inflict natural mortality - animal_community_instance.inflict_non_predation_mortality_community(dt) - - number_of_days = float(dt / timedelta64(1, "D")) - - # Assert the inflict_non_predation_mortality method was called for each cohort - for cohorts in animal_community_instance.animal_cohorts.values(): - for cohort in cohorts: - mock_mortality.assert_called_with( - number_of_days, animal_community_instance.carcass_pool - ) - - # Check if cohorts with no individuals left are flagged as not alive - for cohorts in animal_community_instance.animal_cohorts.values(): - for cohort in cohorts: - if cohort.individuals <= 0: - assert ( - not cohort.is_alive - ), "Cohort with no individuals should be marked as not alive" - assert ( - cohort - not in animal_community_instance.animal_cohorts[cohort.name] - ), "Dead cohort should be removed from the community" - - def test_metamorphose( - self, - mocker, - animal_community_instance, - caterpillar_cohort_instance, - butterfly_cohort_instance, - ): - """Test the metamorphose method for different scenarios.""" - - larval_cohort = caterpillar_cohort_instance - larval_cohort.is_alive = True - - adult_functional_group = butterfly_cohort_instance.functional_group - adult_functional_group.birth_mass = 5.0 - mock_get_functional_group_by_name = mocker.patch( - "virtual_ecosystem.models.animal.animal_communities.get_functional_group_by_name", - return_value=adult_functional_group, - ) - animal_community_instance.animal_cohorts["butterfly"] = [] - - mock_remove_dead_cohort = mocker.patch.object( - animal_community_instance, "remove_dead_cohort" - ) - - # Verify - number_dead = ceil( - larval_cohort.individuals * larval_cohort.constants.metamorph_mortality - ) - expected_individuals = larval_cohort.individuals - number_dead - - animal_community_instance.metamorphose(larval_cohort) - - assert not larval_cohort.is_alive - assert len(animal_community_instance.animal_cohorts["butterfly"]) == 1 - assert ( - animal_community_instance.animal_cohorts["butterfly"][0].individuals - == expected_individuals - ) - mock_remove_dead_cohort.assert_called_once_with(larval_cohort) - mock_get_functional_group_by_name.assert_called_once_with( - animal_community_instance.functional_groups, - larval_cohort.functional_group.offspring_functional_group, - ) - - @pytest.mark.parametrize( - "mass_current, expected_caterpillar_count, expected_butterfly_count," - "expected_is_alive", - [ - pytest.param( - 0.9, # Caterpillar mass is below the adult mass threshold - 1, # Caterpillar count should remain the same - 0, # Butterfly count should remain the same - True, # Caterpillar should still be alive - id="Below_mass_threshold", - ), - pytest.param( - 1.1, # Caterpillar mass is above the adult mass threshold - 0, # Caterpillar count should decrease - 1, # Butterfly count should increase - False, # Caterpillar should no longer be alive - id="Above_mass_threshold", - ), - ], - ) - def test_metamorphose_community( - self, - animal_community_instance, - caterpillar_cohort_instance, - mass_current, - expected_caterpillar_count, - expected_butterfly_count, - expected_is_alive, - ): - """Test the metamorphosis behavior of metamorphose_community.""" - - # Manually set the mass_current for the caterpillar cohort - caterpillar_cohort = caterpillar_cohort_instance - caterpillar_cohort.mass_current = ( - caterpillar_cohort.functional_group.adult_mass * mass_current - ) - - # Initialize the animal_cohorts with both caterpillar and butterfly entries - animal_community_instance.animal_cohorts = { - "caterpillar": [caterpillar_cohort], - "butterfly": [], - } - - # Initial counts - initial_caterpillar_count = len( - animal_community_instance.animal_cohorts.get("caterpillar", []) - ) - initial_butterfly_count = len( - animal_community_instance.animal_cohorts.get("butterfly", []) - ) - - assert initial_caterpillar_count == 1 - assert initial_butterfly_count == 0 - - # Execution: apply metamorphose to the community - animal_community_instance.metamorphose_community() - - # New counts after metamorphosis - new_caterpillar_count = len( - animal_community_instance.animal_cohorts.get("caterpillar", []) - ) - new_butterfly_count = len( - animal_community_instance.animal_cohorts.get("butterfly", []) - ) - - # Assertions - assert new_caterpillar_count == expected_caterpillar_count - assert new_butterfly_count == expected_butterfly_count - assert caterpillar_cohort.is_alive == expected_is_alive diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 096a7ee47..21b083829 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -11,7 +11,7 @@ @pytest.fixture def prepared_animal_model_instance( - animal_data_for_model_instance, + dummy_animal_data, fixture_core_components, functional_group_list_instance, constants_instance, @@ -20,7 +20,7 @@ def prepared_animal_model_instance( from virtual_ecosystem.models.animal.animal_model import AnimalModel model = AnimalModel( - data=animal_data_for_model_instance, + data=dummy_animal_data, core_components=fixture_core_components, functional_groups=functional_group_list_instance, model_constants=constants_instance, @@ -29,702 +29,1290 @@ def prepared_animal_model_instance( return model -def test_animal_model_initialization( - animal_data_for_model_instance, - fixture_core_components, - functional_group_list_instance, - constants_instance, -): - """Test `AnimalModel` initialization.""" - from virtual_ecosystem.core.base_model import BaseModel - from virtual_ecosystem.models.animal.animal_model import AnimalModel +class TestAnimalModel: + """Test the AnimalModel class.""" - # Initialize model - model = AnimalModel( - data=animal_data_for_model_instance, - core_components=fixture_core_components, - functional_groups=functional_group_list_instance, - model_constants=constants_instance, - ) + def test_animal_model_initialization( + self, + dummy_animal_data, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test `AnimalModel` initialization.""" + from virtual_ecosystem.core.base_model import BaseModel + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Initialize model + model = AnimalModel( + data=dummy_animal_data, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) - # In cases where it passes then checks that the object has the right properties - assert isinstance(model, BaseModel) - assert model.model_name == "animal" - assert str(model) == "A animal model instance" - assert repr(model) == "AnimalModel(update_interval=1209600 seconds)" - assert isinstance(model.communities, dict) - - -@pytest.mark.parametrize( - "config_string,raises,expected_log_entries", - [ - pytest.param( - """[core.timing] - start_date = "2020-01-01" - update_interval = "7 days" - [[animal.functional_groups]] - name = "carnivorous_bird" - taxa = "bird" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.1 - adult_mass = 1.0 - [[animal.functional_groups]] - name = "herbivorous_bird" - taxa = "bird" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_bird" - excretion_type = "uricotelic" - birth_mass = 0.05 - adult_mass = 0.5 - [[animal.functional_groups]] - name = "carnivorous_mammal" - taxa = "mammal" - diet = "carnivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 4.0 - adult_mass = 40.0 - [[animal.functional_groups]] - name = "herbivorous_mammal" - taxa = "mammal" - diet = "herbivore" - metabolic_type = "endothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_mammal" - excretion_type = "ureotelic" - birth_mass = 1.0 - adult_mass = 10.0 - [[animal.functional_groups]] - name = "carnivorous_insect" - taxa = "insect" - diet = "carnivore" - metabolic_type = "ectothermic" - reproductive_type = "iteroparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "carnivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.001 - adult_mass = 0.01 - [[animal.functional_groups]] - name = "herbivorous_insect" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "direct" - development_status = "adult" - offspring_functional_group = "herbivorous_insect" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "butterfly" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "semelparous" - development_type = "indirect" - development_status = "adult" - offspring_functional_group = "caterpillar" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - [[animal.functional_groups]] - name = "caterpillar" - taxa = "insect" - diet = "herbivore" - metabolic_type = "ectothermic" - reproductive_type = "nonreproductive" - development_type = "indirect" - development_status = "larval" - offspring_functional_group = "butterfly" - excretion_type = "uricotelic" - birth_mass = 0.0005 - adult_mass = 0.005 - """, - does_not_raise(), - ( - (INFO, "Initialised animal.AnimalConsts from config"), + # In cases where it passes then checks that the object has the right properties + assert isinstance(model, BaseModel) + assert model.model_name == "animal" + assert str(model) == "A animal model instance" + assert repr(model) == "AnimalModel(update_interval=1209600 seconds)" + assert isinstance(model.communities, dict) + + @pytest.mark.parametrize( + "raises,expected_log_entries", + [ + pytest.param( + does_not_raise(), ( - INFO, - "Information required to initialise the animal model successfully " - "extracted.", + (INFO, "Initialised animal.AnimalConsts from config"), + ( + INFO, + "Information required to initialise the animal model" + " successfully extracted.", + ), + (INFO, "Replacing data array for 'total_animal_respiration'"), + (INFO, "Adding data array for 'population_densities'"), + (INFO, "Adding data array for 'decomposed_excrement_carbon'"), + (INFO, "Adding data array for 'decomposed_excrement_nitrogen'"), + (INFO, "Adding data array for 'decomposed_excrement_phosphorus'"), + (INFO, "Adding data array for 'decomposed_carcasses_carbon'"), + (INFO, "Adding data array for 'decomposed_carcasses_nitrogen'"), + (INFO, "Adding data array for 'decomposed_carcasses_phosphorus'"), + ( + INFO, + "Adding data array for 'litter_consumption_above_metabolic'", + ), + ( + INFO, + "Adding data array for 'litter_consumption_above_structural'", + ), + (INFO, "Adding data array for 'litter_consumption_woody'"), + ( + INFO, + "Adding data array for 'litter_consumption_below_metabolic'", + ), + ( + INFO, + "Adding data array for 'litter_consumption_below_structural'", + ), + (INFO, "Adding data array for 'herbivory_waste_leaf_carbon'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_nitrogen'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_phosphorus'"), + (INFO, "Adding data array for 'herbivory_waste_leaf_lignin'"), ), - (INFO, "Adding data array for 'total_animal_respiration'"), - (INFO, "Adding data array for 'population_densities'"), - (INFO, "Adding data array for 'decomposed_excrement_carbon'"), - (INFO, "Adding data array for 'decomposed_excrement_nitrogen'"), - (INFO, "Adding data array for 'decomposed_excrement_phosphorus'"), - (INFO, "Adding data array for 'decomposed_carcasses_carbon'"), - (INFO, "Adding data array for 'decomposed_carcasses_nitrogen'"), - (INFO, "Adding data array for 'decomposed_carcasses_phosphorus'"), - (INFO, "Adding data array for 'litter_consumption_above_metabolic'"), - (INFO, "Adding data array for 'litter_consumption_above_structural'"), - (INFO, "Adding data array for 'litter_consumption_woody'"), - (INFO, "Adding data array for 'litter_consumption_below_metabolic'"), - (INFO, "Adding data array for 'litter_consumption_below_structural'"), - (INFO, "Adding data array for 'herbivory_waste_leaf_carbon'"), - (INFO, "Adding data array for 'herbivory_waste_leaf_nitrogen'"), - (INFO, "Adding data array for 'herbivory_waste_leaf_phosphorus'"), - (INFO, "Adding data array for 'herbivory_waste_leaf_lignin'"), + id="success", ), - id="success", - ), - ], -) -def test_generate_animal_model( - caplog, - animal_data_for_model_instance, - config_string, - raises, - expected_log_entries, -): - """Test that the function to initialise the animal model behaves as expected.""" - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.models.animal.animal_model import AnimalModel - - # Build the config object and core components - config = Config(cfg_strings=config_string) - core_components = CoreComponents(config) - caplog.clear() - - # Check whether model is initialised (or not) as expected - with raises: - model = AnimalModel.from_config( - data=animal_data_for_model_instance, - core_components=core_components, - config=config, - ) - - # Run the update step (once this does something should check output) - model.update(time_index=0) - - # Final check that expected logging entries are produced - log_check(caplog, expected_log_entries) - - for record in caplog.records: - print(f"Level: {record.levelname}, Message: {record.message}") + ], + ) + def test_generate_animal_model( + self, + caplog, + dummy_animal_data, + animal_fixture_config, # Use the config fixture + raises, + expected_log_entries, + ): + """Test that the function to initialise the animal model behaves as expected.""" + from virtual_ecosystem.core.core_components import CoreComponents + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Build the config object and core components using the fixture + config = animal_fixture_config + core_components = CoreComponents(config) + caplog.clear() + + # Check whether model is initialised (or not) as expected + with raises: + model = AnimalModel.from_config( + data=dummy_animal_data, + core_components=core_components, + config=config, + ) + # Run the update step (once this does something should check output) + model.update(time_index=0) -def test_get_community_by_key(animal_model_instance): - """Test the `get_community_by_key` method.""" + # Print the captured log messages to debug + for record in caplog.records: + print(f"Log Level: {record.levelno}, Message: {record.message}") - from virtual_ecosystem.models.animal.animal_model import AnimalCommunity + # Filter out stochastic log entries + filtered_records = [ + record + for record in caplog.records + if "No individuals in cohort to forage." not in record.message + ] - # If you know that your model_instance should have a community with key 0 - community_0 = animal_model_instance.get_community_by_key(0) + # Create a new caplog object to pass to log_check + class FilteredCaplog: + records = filtered_records - # Ensure it returns the right type and the community key matches - assert isinstance( - community_0, AnimalCommunity - ), "Expected instance of AnimalCommunity" - assert community_0.community_key == 0, "Expected the community with key 0" + filtered_caplog = FilteredCaplog() - # Perhaps you have more keys you expect, you can add similar checks: - community_1 = animal_model_instance.get_community_by_key(1) - assert isinstance(community_1, AnimalCommunity) - assert community_1.community_key == 1, "Expected the community with key 1" + # Final check that expected logging entries are produced + log_check(filtered_caplog, expected_log_entries) - # Test for an invalid key, expecting an error - with pytest.raises(KeyError): - animal_model_instance.get_community_by_key(999) + for record in caplog.records: + print(f"Level: {record.levelname}, Message: {record.message}") + def test_update_method_sequence(self, mocker, prepared_animal_model_instance): + """Test update to ensure it runs the community methods in order.""" -def test_update_method_sequence(mocker, prepared_animal_model_instance): - """Test update to ensure it runs the community methods in order.""" - method_names = [ - "forage_community", - "migrate_community", - "birth_community", - "metamorphose_community", - "metabolize_community", - "inflict_non_predation_mortality_community", - "remove_dead_cohort_community", - "increase_age_community", - ] + # List of methods that should be called in the update sequence + method_names = [ + "forage_community", + "migrate_community", + "birth_community", + "metamorphose_community", + "metabolize_community", + "inflict_non_predation_mortality_community", + "remove_dead_cohort_community", + "increase_age_community", + ] - # Setup mock methods using spy - for community in prepared_animal_model_instance.communities.values(): + # Setup mock methods using spy on the prepared_animal_model_instance itself for method_name in method_names: - mocker.spy(community, method_name) + mocker.spy(prepared_animal_model_instance, method_name) - prepared_animal_model_instance.update(time_index=0) + # Call the update method + prepared_animal_model_instance.update(time_index=0) - # Now, let's verify the order of the calls for each community - for community in prepared_animal_model_instance.communities.values(): + # Verify the order of the method calls called_methods = [] for method_name in method_names: - method = getattr(community, method_name) + method = getattr(prepared_animal_model_instance, method_name) # If the method was called, add its name to the list if method.spy_return is not None or method.call_count > 0: called_methods.append(method_name) - # Verify the called_methods list matches the expected method_names list + # Ensure the methods were called in the expected order assert ( called_methods == method_names - ), f"Methods called in wrong order: {called_methods} for community {community}" + ), f"Methods called in wrong order: {called_methods}" + + def test_update_method_time_index_argument( + self, + prepared_animal_model_instance, + ): + """Test update to ensure the time index argument does not create an error.""" + time_index = 5 + prepared_animal_model_instance.update(time_index=time_index) -def test_update_method_time_index_argument( - prepared_animal_model_instance, -): - """Test update to ensure the time index argument does not create an error.""" + assert True + + def test_setup_initializes_total_animal_respiration( + self, + prepared_animal_model_instance, + ): + """Test that the setup method for the total_animal_respiration variable.""" + import numpy as np + from xarray import DataArray - time_index = 5 - prepared_animal_model_instance.update(time_index=time_index) + # Check if 'total_animal_respiration' is in the data object + assert ( + "total_animal_respiration" in prepared_animal_model_instance.data + ), "'total_animal_respiration' should be initialized in the data object." + + # Retrieve the total_animal_respiration DataArray from the model's data object + total_animal_respiration = prepared_animal_model_instance.data[ + "total_animal_respiration" + ] + + # Check that total_animal_respiration is an instance of xarray.DataArray + assert isinstance( + total_animal_respiration, DataArray + ), "'total_animal_respiration' should be an instance of xarray.DataArray." + + # Check the initial values of total_animal_respiration are all zeros + assert np.all( + total_animal_respiration.values == 0 + ), "Initial values of 'total_animal_respiration' should be all zeros." + + # Optionally, you can also check the dimensions and coordinates + # This is useful if your setup method is supposed to initialize the data + # variable with specific dimensions or coordinates based on your model's + # structure + assert ( + "cell_id" in total_animal_respiration.dims + ), "'cell_id' should be a dimension of 'total_animal_respiration'." - assert True + def test_population_density_initialization( + self, + prepared_animal_model_instance, + ): + """Test the initialization of the population density data variable.""" + # Check that 'population_densities' is in the data + assert ( + "population_densities" in prepared_animal_model_instance.data.data.data_vars + ), "'population_densities' data variable not found in Data object after setup." + + # Retrieve the population densities data variable + population_densities = prepared_animal_model_instance.data[ + "population_densities" + ] + + # Check dimensions + expected_dims = ["community_id", "functional_group_id"] + assert all( + dim in population_densities.dims for dim in expected_dims + ), f"Expected dimensions {expected_dims} not found in 'population_densities'." + + # Check coordinates + # you should adjust according to actual community IDs and functional group names + expected_community_ids = list(prepared_animal_model_instance.communities.keys()) + expected_functional_group_names = [ + fg.name for fg in prepared_animal_model_instance.functional_groups + ] + assert ( + population_densities.coords["community_id"].values.tolist() + == expected_community_ids + ), "Community IDs in 'population_densities' do not match expected values." + assert ( + population_densities.coords["functional_group_id"].values.tolist() + == expected_functional_group_names + ), "Functional group names in 'population_densities' do not match" + "expected values." + + # Assuming densities have been updated, check if densities are greater than or + # equal to zero + assert np.all( + population_densities.values >= 0 + ), "Population densities should be greater than or equal to zero." + + def test_update_population_densities(self, prepared_animal_model_instance): + """Test that the update_population_densities method correctly updates.""" + + # Set up expected densities + expected_densities = {} + + # Manually calculate expected densities based on the cohorts in the community + for ( + community_id, + community, + ) in prepared_animal_model_instance.communities.items(): + expected_densities[community_id] = {} + + # Iterate through the list of cohorts in each community + for cohort in community: + fg_name = cohort.functional_group.name + total_individuals = cohort.individuals + community_area = prepared_animal_model_instance.data.grid.cell_area + density = total_individuals / community_area + + # Accumulate density for each functional group + if fg_name not in expected_densities[community_id]: + expected_densities[community_id][fg_name] = 0.0 + expected_densities[community_id][fg_name] += density + + # Run the method under test + prepared_animal_model_instance.update_population_densities() + + # Retrieve the updated population densities data variable + population_densities = prepared_animal_model_instance.data[ + "population_densities" + ] + + # Verify updated densities match expected values + for community_id in expected_densities: + for fg_name in expected_densities[community_id]: + calculated_density = population_densities.sel( + community_id=community_id, functional_group_id=fg_name + ).item() + expected_density = expected_densities[community_id][fg_name] + assert calculated_density == pytest.approx(expected_density), ( + f"Mismatch in density for community {community_id}" + " and FG {fg_name}. " + f"Expected: {expected_density}, Found: {calculated_density}" + ) + + def test_populate_litter_pools( + self, + litter_data_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test function to populate animal consumable litter pool works properly.""" + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + model = AnimalModel( + data=litter_data_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) -def test_populate_litter_pools( - litter_data_instance, - fixture_core_components, - functional_group_list_instance, - constants_instance, -): - """Test that function to populate animal consumable litter pool works properly.""" - from virtual_ecosystem.models.animal.animal_model import AnimalModel + litter_pools = model.populate_litter_pools() + # Check that all five pools have been populated, with the correct values + pool_names = [ + "above_metabolic", + "above_structural", + "woody", + "below_metabolic", + "below_structural", + ] + for pool_name in pool_names: + assert np.allclose( + litter_pools[pool_name].mass_current, + litter_data_instance[f"litter_pool_{pool_name}"] + * fixture_core_components.grid.cell_area, + ) + assert np.allclose( + litter_pools[pool_name].c_n_ratio, + litter_data_instance[f"c_n_ratio_{pool_name}"], + ) + assert np.allclose( + litter_pools[pool_name].c_p_ratio, + litter_data_instance[f"c_p_ratio_{pool_name}"], + ) - model = AnimalModel( - data=litter_data_instance, - core_components=fixture_core_components, - functional_groups=functional_group_list_instance, - model_constants=constants_instance, - ) + def test_calculate_total_litter_consumption( + self, + litter_data_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test calculation of total consumption of litter by animals is correct.""" + from copy import deepcopy + + from virtual_ecosystem.models.animal.animal_model import AnimalModel + from virtual_ecosystem.models.animal.decay import LitterPool + + model = AnimalModel( + data=litter_data_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) - litter_pools = model.populate_litter_pools() - # Check that all five pools have been populated, with the correct values - pool_names = [ - "above_metabolic", - "above_structural", - "woody", - "below_metabolic", - "below_structural", - ] - for pool_name in pool_names: + new_data = deepcopy(litter_data_instance) + # Add new values for each pool + new_data["litter_pool_above_metabolic"] = ( + litter_data_instance["litter_pool_above_metabolic"] - 0.03 + ) + new_data["litter_pool_above_structural"] = ( + litter_data_instance["litter_pool_above_structural"] - 0.04 + ) + new_data["litter_pool_woody"] = litter_data_instance["litter_pool_woody"] - 1.2 + new_data["litter_pool_below_metabolic"] = ( + litter_data_instance["litter_pool_below_metabolic"] - 0.06 + ) + new_data["litter_pool_below_structural"] = ( + litter_data_instance["litter_pool_below_structural"] - 0.01 + ) + + # Make an updated set of litter pools + pool_names = [ + "above_metabolic", + "above_structural", + "woody", + "below_metabolic", + "below_structural", + ] + new_litter_pools = { + pool_name: LitterPool( + pool_name=pool_name, + data=new_data, + cell_area=fixture_core_components.grid.cell_area, + ) + for pool_name in pool_names + } + + # Calculate litter consumption + consumption = model.calculate_total_litter_consumption( + litter_pools=new_litter_pools + ) + + assert np.allclose( + consumption["litter_consumption_above_metabolic"], + 0.03 * np.ones(4), + ) + assert np.allclose( + consumption["litter_consumption_above_structural"], + 0.04 * np.ones(4), + ) assert np.allclose( - litter_pools[pool_name].mass_current, - litter_data_instance[f"litter_pool_{pool_name}"] - * fixture_core_components.grid.cell_area, + consumption["litter_consumption_woody"], + 1.2 * np.ones(4), ) assert np.allclose( - litter_pools[pool_name].c_n_ratio, - litter_data_instance[f"c_n_ratio_{pool_name}"], + consumption["litter_consumption_below_metabolic"], + 0.06 * np.ones(4), ) assert np.allclose( - litter_pools[pool_name].c_p_ratio, - litter_data_instance[f"c_p_ratio_{pool_name}"], + consumption["litter_consumption_below_structural"], + 0.01 * np.ones(4), ) + def test_calculate_density_for_cohort(self, prepared_animal_model_instance, mocker): + """Test the calculate_density_for_cohort method.""" -def test_calculate_total_litter_consumption( - litter_data_instance, - fixture_core_components, - functional_group_list_instance, - constants_instance, -): - """Test that calculation of total consumption of litter by animals is correct.""" - from copy import deepcopy + mock_cohort = mocker.MagicMock() + mock_cohort.individuals = 100 # Example number of individuals - from virtual_ecosystem.models.animal.animal_model import AnimalModel - from virtual_ecosystem.models.animal.decay import LitterPool + # Set a known community area in the model's data.grid.cell_area + prepared_animal_model_instance.data.grid.cell_area = 2000 # Example area in m2 - model = AnimalModel( - data=litter_data_instance, - core_components=fixture_core_components, - functional_groups=functional_group_list_instance, - model_constants=constants_instance, - ) + # Expected density = individuals / area + expected_density = ( + mock_cohort.individuals / prepared_animal_model_instance.data.grid.cell_area + ) - new_data = deepcopy(litter_data_instance) - # Add new values for each pool - new_data["litter_pool_above_metabolic"] = ( - litter_data_instance["litter_pool_above_metabolic"] - 0.03 - ) - new_data["litter_pool_above_structural"] = ( - litter_data_instance["litter_pool_above_structural"] - 0.04 - ) - new_data["litter_pool_woody"] = litter_data_instance["litter_pool_woody"] - 1.2 - new_data["litter_pool_below_metabolic"] = ( - litter_data_instance["litter_pool_below_metabolic"] - 0.06 - ) - new_data["litter_pool_below_structural"] = ( - litter_data_instance["litter_pool_below_structural"] - 0.01 - ) + # Calculate density using the method under test + calculated_density = ( + prepared_animal_model_instance.calculate_density_for_cohort(mock_cohort) + ) - # Make an updated set of litter pools - pool_names = [ - "above_metabolic", - "above_structural", - "woody", - "below_metabolic", - "below_structural", - ] - new_litter_pools = { - pool_name: LitterPool( - pool_name=pool_name, - data=new_data, - cell_area=fixture_core_components.grid.cell_area, - ) - for pool_name in pool_names - } - - # Calculate litter consumption - consumption = model.calculate_total_litter_consumption( - litter_pools=new_litter_pools - ) + # Assert the calculated density matches the expected density + assert calculated_density == pytest.approx(expected_density), ( + f"Calculated density ({calculated_density}) " + f"did not match expected density ({expected_density})." + ) - assert np.allclose( - consumption["litter_consumption_above_metabolic"], - 0.03 * np.ones(4), - ) - assert np.allclose( - consumption["litter_consumption_above_structural"], - 0.04 * np.ones(4), - ) - assert np.allclose( - consumption["litter_consumption_woody"], - 1.2 * np.ones(4), - ) - assert np.allclose( - consumption["litter_consumption_below_metabolic"], - 0.06 * np.ones(4), - ) - assert np.allclose( - consumption["litter_consumption_below_structural"], - 0.01 * np.ones(4), - ) + def test_initialize_communities( + self, + animal_data_for_model_instance, + fixture_core_components, + functional_group_list_instance, + constants_instance, + ): + """Test that `_initialize_communities` generates cohorts.""" + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.animal_model import AnimalModel -def test_calculate_litter_additions_from_herbivory(functional_group_list_instance): - """Test that calculation of additions to the litter by animals is correct.""" - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.core.data import Data - from virtual_ecosystem.core.grid import Grid - from virtual_ecosystem.models.animal.animal_model import AnimalModel + # Initialize the model + model = AnimalModel( + data=animal_data_for_model_instance, + core_components=fixture_core_components, + functional_groups=functional_group_list_instance, + model_constants=constants_instance, + ) - # Build the config object and core components - config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') - core_components = CoreComponents(config) + # Call the method to initialize communities + model._initialize_communities(functional_group_list_instance) - # Create a small data object to work with - grid = Grid(cell_nx=2, cell_ny=2) - data = Data(grid) + # Assert that cohorts have been generated in each community + for cell_id in animal_data_for_model_instance.grid.cell_id: + assert len(model.communities[cell_id]) > 0 + for cohort in model.communities[cell_id]: + assert isinstance(cohort, AnimalCohort) - # Use it to initialise the model - model = AnimalModel( - data=data, - core_components=core_components, - functional_groups=functional_group_list_instance, - ) + # Assert that cohorts are stored in the model's cohort dictionary + assert len(model.cohorts) > 0 - # Update the herbivory waste pools - waste_leaves = [3.4e-1, 7.6e2, 4.5e1, 1.9e2] - for waste, community in zip(waste_leaves, model.communities.values()): - community.leaf_waste_pool.mass_current = waste + def test_abandon_communities( + self, + animal_model_instance, + herbivore_cohort_instance, + ): + """Test that `abandon_communities` removes a cohort from all communities.""" + + # Assign the cohort to multiple territories (two cells) + cohort = herbivore_cohort_instance + cohort.territory = [ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ] + + # Add the cohort to multiple communities in the animal model + animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ].append(cohort) + animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ].append(cohort) + + # Assert that the cohort is present in the communities before abandonment + assert ( + cohort + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + cohort + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) - expected_additions = { - "herbivory_waste_leaf_carbon": [3.4e-5, 7.6e-2, 4.5e-3, 1.9e-2], - "herbivory_waste_leaf_nitrogen": [20.0, 20.0, 20.0, 20.0], - "herbivory_waste_leaf_phosphorus": [150.0, 150.0, 150.0, 150.0], - "herbivory_waste_leaf_lignin": [0.25, 0.25, 0.25, 0.25], - } + # Call the abandon_communities method to remove the cohort + animal_model_instance.abandon_communities(cohort) - # Calculate litter additions - actual_additions = model.calculate_litter_additions_from_herbivory() + # Assert that the cohort is removed from both communities + assert ( + cohort + not in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + cohort + not in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) - assert set(actual_additions.keys()) == set(expected_additions.keys()) + def test_update_community_occupancy( + self, animal_model_instance, herbivore_cohort_instance, mocker + ): + """Test update_community_occupancy.""" + + # Mock the get_territory_cells method to return specific territory cells + mocker.patch.object( + herbivore_cohort_instance, + "get_territory_cells", + return_value=[ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ], + ) - for name in actual_additions.keys(): - assert np.allclose(actual_additions[name], expected_additions[name]) + # Spy on the update_territory method to check if it's called + spy_update_territory = mocker.spy(herbivore_cohort_instance, "update_territory") - # Check that the waste pools have been emptied for the next run - assert np.allclose(community.leaf_waste_pool.mass_current, [0.0, 0.0, 0.0, 0.0]) + # Choose a centroid key (e.g., the first grid cell) + centroid_key = animal_model_instance.data.grid.cell_id[0] + # Call the method to update community occupancy + animal_model_instance.update_community_occupancy( + herbivore_cohort_instance, centroid_key + ) -def test_calculate_soil_additions(functional_group_list_instance): - """Test that soil additions from animal model are calculated correctly.""" + # Check if the cohort's territory was updated correctly + spy_update_territory.assert_called_once_with( + [ + animal_model_instance.data.grid.cell_id[0], + animal_model_instance.data.grid.cell_id[1], + ] + ) - from virtual_ecosystem.core.config import Config - from virtual_ecosystem.core.core_components import CoreComponents - from virtual_ecosystem.core.data import Data - from virtual_ecosystem.core.grid import Grid - from virtual_ecosystem.models.animal.animal_model import AnimalModel + # Check if the cohort has been added to the appropriate communities + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[0] + ] + ) + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[ + animal_model_instance.data.grid.cell_id[1] + ] + ) - # Build the config object and core components - config = Config(cfg_strings='[core.timing]\nupdate_interval="1 week"') - core_components = CoreComponents(config) + def test_migrate(self, animal_model_instance, herbivore_cohort_instance, mocker): + """Test that `migrate` correctly moves an AnimalCohort between grid cells.""" - # Create a small data object to work with - grid = Grid(cell_nx=2, cell_ny=2) - data = Data(grid) + # Mock the abandonment and community occupancy update methods + mock_abandon_communities = mocker.patch.object( + animal_model_instance, "abandon_communities" + ) + mock_update_community_occupancy = mocker.patch.object( + animal_model_instance, "update_community_occupancy" + ) - # Use it to initialise the model - model = AnimalModel( - data=data, - core_components=core_components, - functional_groups=functional_group_list_instance, - ) + # Assign the cohort to a specific starting grid cell + initial_cell = animal_model_instance.data.grid.cell_id[0] + destination_cell = animal_model_instance.data.grid.cell_id[1] - # Update the waste pools - decomposed_excrement_carbon = [3.5e-3, 5.6e-2, 5.9e-2, 2.3e0] - for carbon, community in zip( - decomposed_excrement_carbon, model.communities.values() - ): - community.excrement_pool.decomposed_carbon = carbon - decomposed_excrement_nitrogen = [2.4e-4, 7.3e-3, 3.4e-3, 9.3e-2] - for nitrogen, community in zip( - decomposed_excrement_nitrogen, model.communities.values() - ): - community.excrement_pool.decomposed_nitrogen = nitrogen - decomposed_excrement_phosphorus = [5.4e-6, 1.7e-4, 4.5e-5, 9.8e-5] - for phosphorus, community in zip( - decomposed_excrement_phosphorus, model.communities.values() - ): - community.excrement_pool.decomposed_phosphorus = phosphorus + herbivore_cohort_instance.centroid_key = initial_cell + animal_model_instance.communities[initial_cell].append( + herbivore_cohort_instance + ) - decomposed_carcasses_carbon = [1.7e2, 7.5e0, 3.4e1, 8.1e1] - for carbon, community in zip( - decomposed_carcasses_carbon, model.communities.values() - ): - community.carcass_pool.decomposed_carbon = carbon - decomposed_carcasses_nitrogen = [9.3e-2, 2.4e-4, 7.3e-3, 3.4e-3] - for nitrogen, community in zip( - decomposed_carcasses_nitrogen, model.communities.values() - ): - community.carcass_pool.decomposed_nitrogen = nitrogen - decomposed_carcasses_phosphorus = [9.8e-5, 5.4e-6, 1.7e-4, 4.5e-5] - for phosphorus, community in zip( - decomposed_carcasses_phosphorus, model.communities.values() - ): - community.carcass_pool.decomposed_phosphorus = phosphorus + # Check that the cohort is in the initial community before migration + assert ( + herbivore_cohort_instance in animal_model_instance.communities[initial_cell] + ) - # Calculate litter additions - soil_additions = model.calculate_soil_additions() + # Call the migrate method to move the cohort to the destination cell + animal_model_instance.migrate(herbivore_cohort_instance, destination_cell) - # Check that litter addition pools are as expected - assert np.allclose( - soil_additions["decomposed_excrement_carbon"], - [5e-08, 8e-07, 8.42857e-07, 3.28571e-05], - ) - assert np.allclose( - soil_additions["decomposed_excrement_nitrogen"], - [3.4285714e-9, 1.0428571e-7, 4.8571429e-8, 1.3285714e-6], - ) - assert np.allclose( - soil_additions["decomposed_excrement_phosphorus"], - [7.7142857e-11, 2.4285714e-9, 6.4285714e-10, 1.4e-9], - ) - assert np.allclose( - soil_additions["decomposed_carcasses_carbon"], - [2.42857e-3, 1.0714e-4, 4.8571e-4, 1.15714e-3], - ) - assert np.allclose( - soil_additions["decomposed_carcasses_nitrogen"], - [1.3285714e-6, 3.4285714e-9, 1.0428571e-7, 4.8571429e-8], - ) - assert np.allclose( - soil_additions["decomposed_carcasses_phosphorus"], - [1.4e-9, 7.7142857e-11, 2.4285714e-9, 6.4285714e-10], - ) + # Assert that the cohort is no longer in the initial community + assert ( + herbivore_cohort_instance + not in animal_model_instance.communities[initial_cell] + ) - # Check that the function has reset the pools correctly - assert np.allclose( - [ - community.excrement_pool.decomposed_carbon - for community in model.communities.values() - ], - 0.0, - ) - assert np.allclose( + # Assert that the cohort is now in the destination community + assert ( + herbivore_cohort_instance + in animal_model_instance.communities[destination_cell] + ) + + # Assert that the centroid of the cohort has been updated + assert herbivore_cohort_instance.centroid_key == destination_cell + + # Check that abandon_communities and update_community_occupancy were called + mock_abandon_communities.assert_called_once_with(herbivore_cohort_instance) + mock_update_community_occupancy.assert_called_once_with( + herbivore_cohort_instance, destination_cell + ) + + @pytest.mark.parametrize( + "mass_ratio, age, probability_output, should_migrate", [ - community.carcass_pool.decomposed_carbon - for community in model.communities.values() + (0.5, 5.0, False, True), # Starving non-juvenile, should migrate + ( + 1.0, + 0.0, + False, + False, + ), # Well-fed juvenile, low probability, should not migrate + ( + 1.0, + 0.0, + 1.0, + True, + ), # Well-fed juvenile, high probability (1.0), should migrate + ( + 0.5, + 0.0, + 1.0, + True, + ), # Starving juvenile, high probability (1.0), should migrate + ( + 0.5, + 0.0, + 0.0, + True, + ), # Starving juvenile, low probability (0.0), should migrate + (1.0, 5.0, False, False), # Well-fed non-juvenile, should not migrate ], - 0.0, - ) - assert np.allclose( - [ - community.excrement_pool.decomposed_nitrogen - for community in model.communities.values() + ids=[ + "starving_non_juvenile", + "well_fed_juvenile_low_prob", + "well_fed_juvenile_high_prob", + "starving_juvenile_high_prob", + "starving_juvenile_low_prob", + "well_fed_non_juvenile", ], - 0.0, ) - assert np.allclose( + def test_migrate_community( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + mass_ratio, + age, + probability_output, + should_migrate, + ): + """Test migrate_community.""" + + # Empty the communities and cohorts before the test + animal_model_instance.communities = { + cell_id: [] for cell_id in animal_model_instance.communities + } + animal_model_instance.cohorts = {} + + # Set up mock cohort with dynamic mass and age values + cohort_id = herbivore_cohort_instance.id + herbivore_cohort_instance.age = age + herbivore_cohort_instance.mass_current = ( + herbivore_cohort_instance.functional_group.adult_mass * mass_ratio + ) + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + + # Mock `is_below_mass_threshold` to simulate starvation + is_starving = mass_ratio < 1.0 + mocker.patch.object( + herbivore_cohort_instance, + "is_below_mass_threshold", + return_value=is_starving, + ) + + # Mock the juvenile migration probability based on the test parameter + mocker.patch.object( + herbivore_cohort_instance, + "migrate_juvenile_probability", + return_value=probability_output, + ) + + # Mock the migrate method + mock_migrate = mocker.patch.object(animal_model_instance, "migrate") + + # Call the migrate_community method + animal_model_instance.migrate_community() + + # Check migration behavior + if should_migrate: + # Assert migrate was called with correct cohort + mock_migrate.assert_called_once_with(herbivore_cohort_instance, mocker.ANY) + else: + # Assert migrate was NOT called + mock_migrate.assert_not_called() + + # Assert that starvation check was applied + herbivore_cohort_instance.is_below_mass_threshold.assert_called_once() + + @pytest.mark.parametrize( + "is_cohort_in_model, expected_exception", [ - community.carcass_pool.decomposed_nitrogen - for community in model.communities.values() + (True, None), # Cohort exists, should be removed + (False, KeyError), # Cohort does not exist, KeyError expected ], - 0.0, ) - assert np.allclose( + def test_remove_dead_cohort( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + is_cohort_in_model, + expected_exception, + ): + """Test the remove_dead_cohort method for both success and error cases.""" + + # Setup cohort ID and mock territory + cohort_id = herbivore_cohort_instance.id + herbivore_cohort_instance.territory = [ + 1, + 2, + ] # Simulate a territory covering two cells + + # If cohort should exist, add it to model's cohorts and communities + if is_cohort_in_model: + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + animal_model_instance.communities = { + 1: [herbivore_cohort_instance], + 2: [herbivore_cohort_instance], + } + + # If cohort doesn't exist, make sure it's not in the model + else: + animal_model_instance.cohorts = {} + + if expected_exception: + # Expect KeyError if cohort does not exist + with pytest.raises( + KeyError, match=f"Cohort with ID {cohort_id} does not exist." + ): + animal_model_instance.remove_dead_cohort(herbivore_cohort_instance) + else: + # Call the method to remove the cohort if it exists + animal_model_instance.remove_dead_cohort(herbivore_cohort_instance) + + # Assert that the cohort has been removed from both communities + assert herbivore_cohort_instance not in animal_model_instance.communities[1] + assert herbivore_cohort_instance not in animal_model_instance.communities[2] + + # Assert that the cohort has been removed from the model's cohorts + assert cohort_id not in animal_model_instance.cohorts + + @pytest.mark.parametrize( + "cohort_individuals, should_be_removed", [ - community.excrement_pool.decomposed_phosphorus - for community in model.communities.values() + (0, True), # Cohort with 0 individuals, should be removed + (10, False), # Cohort with >0 individuals, should not be removed ], - 0.0, ) - assert np.allclose( + def test_remove_dead_cohort_community( + self, + animal_model_instance, + herbivore_cohort_instance, + mocker, + cohort_individuals, + should_be_removed, + ): + """Test remove_dead_cohort_community for both dead and alive cohorts.""" + + # Set up cohort with individuals count + herbivore_cohort_instance.individuals = cohort_individuals + cohort_id = herbivore_cohort_instance.id + + # Add the cohort to the model's cohorts and communities + animal_model_instance.cohorts[cohort_id] = herbivore_cohort_instance + herbivore_cohort_instance.territory = [1, 2] # Simulate a territory + animal_model_instance.communities = { + 1: [herbivore_cohort_instance], + 2: [herbivore_cohort_instance], + } + + # Mock remove_dead_cohort to track when it is called + mock_remove_dead_cohort = mocker.patch.object( + animal_model_instance, "remove_dead_cohort" + ) + + # Call the method to remove dead cohorts from the community + animal_model_instance.remove_dead_cohort_community() + + if should_be_removed: + # If the cohort should be removed, check if remove_dead_cohort was called + mock_remove_dead_cohort.assert_called_once_with(herbivore_cohort_instance) + assert ( + herbivore_cohort_instance.is_alive is False + ) # Cohort should be marked as not alive + else: + # If cohort should not be removed, ensure remove_dead_cohort wasn't called + mock_remove_dead_cohort.assert_not_called() + assert ( + herbivore_cohort_instance.is_alive is True + ) # Cohort should still be alive + + @pytest.mark.parametrize( + "functional_group_type, reproductive_mass, mass_current, birth_mass," + "individuals, is_semelparous, expected_offspring", [ - community.carcass_pool.decomposed_phosphorus - for community in model.communities.values() + # Test case for semelparous organism + ("herbivore", 100.0, 1000.0, 10.0, 5, False, 50), + # Test case for iteroparous organism + ("butterfly", 50.0, 200.0, 0.5, 50, True, 15000), ], - 0.0, ) + def test_birth( + self, + animal_model_instance, + herbivore_cohort_instance, + butterfly_cohort_instance, + functional_group_type, + reproductive_mass, + mass_current, + birth_mass, + individuals, + is_semelparous, + expected_offspring, + ): + """Test the birth method with semelparous and iteroparous cohorts.""" + from uuid import uuid4 -def test_setup_initializes_total_animal_respiration( - prepared_animal_model_instance, -): - """Test that the setup method initializes the total_animal_respiration variable.""" - import numpy as np - from xarray import DataArray - - # Check if 'total_animal_respiration' is in the data object - assert ( - "total_animal_respiration" in prepared_animal_model_instance.data - ), "'total_animal_respiration' should be initialized in the data object." - - # Retrieve the total_animal_respiration DataArray from the model's data object - total_animal_respiration = prepared_animal_model_instance.data[ - "total_animal_respiration" - ] - - # Check that total_animal_respiration is an instance of xarray.DataArray - assert isinstance( - total_animal_respiration, DataArray - ), "'total_animal_respiration' should be an instance of xarray.DataArray." - - # Check the initial values of total_animal_respiration are all zeros - assert np.all( - total_animal_respiration.values == 0 - ), "Initial values of 'total_animal_respiration' should be all zeros." - - # Optionally, you can also check the dimensions and coordinates - # This is useful if your setup method is supposed to initialize the data variable - # with specific dimensions or coordinates based on your model's structure - assert ( - "cell_id" in total_animal_respiration.dims - ), "'cell_id' should be a dimension of 'total_animal_respiration'." - - -def test_population_density_initialization( - prepared_animal_model_instance, -): - """Test the initialization of the population density data variable.""" - - # Check that 'population_densities' is in the data - assert ( - "population_densities" in prepared_animal_model_instance.data.data.data_vars - ), "'population_densities' data variable not found in Data object after setup." - - # Retrieve the population densities data variable - population_densities = prepared_animal_model_instance.data["population_densities"] - - # Check dimensions - expected_dims = ["community_id", "functional_group_id"] - assert all( - dim in population_densities.dims for dim in expected_dims - ), f"Expected dimensions {expected_dims} not found in 'population_densities'." - - # Check coordinates - # you should adjust according to actual community IDs and functional group names - expected_community_ids = list(prepared_animal_model_instance.communities.keys()) - expected_functional_group_names = [ - fg.name for fg in prepared_animal_model_instance.functional_groups - ] - assert ( - population_densities.coords["community_id"].values.tolist() - == expected_community_ids - ), "Community IDs in 'population_densities' do not match expected values." - assert ( - population_densities.coords["functional_group_id"].values.tolist() - == expected_functional_group_names - ), "Functional group names in 'population_densities' do not match expected values." - - # Assuming densities have been updated, check if densities are greater than or equal - # to zero - assert np.all( - population_densities.values >= 0 - ), "Population densities should be greater than or equal to zero." - - -def test_update_population_densities(prepared_animal_model_instance): - """Test that the update_population_densities method correctly updates.""" - - # Set up expected densities - expected_densities = {} - - # For simplicity in this example, assume we manually calculate expected densities - # based on your cohort setup logic. In practice, you would calculate these - # based on your specific test setup conditions. - for community_id, community in prepared_animal_model_instance.communities.items(): - expected_densities[community_id] = {} - for fg_name, cohorts in community.animal_cohorts.items(): - total_individuals = sum(cohort.individuals for cohort in cohorts) - community_area = prepared_animal_model_instance.data.grid.cell_area - density = total_individuals / community_area - expected_densities[community_id][fg_name] = density - - # Run the method under test - prepared_animal_model_instance.update_population_densities() - - # Retrieve the updated population densities data variable - population_densities = prepared_animal_model_instance.data["population_densities"] - - # Verify updated densities match expected values - for community_id in expected_densities: - for fg_name in expected_densities[community_id]: - calculated_density = population_densities.sel( - community_id=community_id, functional_group_id=fg_name - ).item() - expected_density = expected_densities[community_id][fg_name] - assert calculated_density == pytest.approx(expected_density), ( - f"Mismatch in density for community {community_id} and FG{fg_name}. " - f"Expected: {expected_density}, Found: {calculated_density}" - ) + # Choose the appropriate cohort instance based on the test case + parent_cohort = ( + herbivore_cohort_instance + if functional_group_type == "herbivore" + else butterfly_cohort_instance + ) + + # Mock the attributes of the parent cohort for the test case + parent_cohort.reproductive_mass = reproductive_mass + parent_cohort.mass_current = mass_current + parent_cohort.functional_group.birth_mass = birth_mass + parent_cohort.individuals = individuals + parent_cohort.functional_group.reproductive_type = ( + "semelparous" if is_semelparous else "iteroparous" + ) + parent_cohort.functional_group.offspring_functional_group = ( + parent_cohort.functional_group.name + ) + + # Set a mock cohort ID + cohort_id = uuid4() + parent_cohort.id = cohort_id + + # Add the parent cohort to the model's cohorts dictionary + animal_model_instance.cohorts[cohort_id] = parent_cohort + + # Store the initial number of cohorts in the model + initial_num_cohorts = len(animal_model_instance.cohorts) + + # Call the birth method (without mocking `get_functional_group_by_name`) + animal_model_instance.birth(parent_cohort) + + # Check if the parent cohort is dead (only if semelparous) + if is_semelparous: + assert parent_cohort.is_alive is False + else: + assert parent_cohort.is_alive is True + + # Check that reproductive mass is reset + assert parent_cohort.reproductive_mass == 0.0 + + # Check the number of offspring generated and added to the cohort list + if is_semelparous: + # For semelparous organisms, the parent dies and the offspring cohort + # replaces it + assert ( + len(animal_model_instance.cohorts) == initial_num_cohorts + ), f"Expected {initial_num_cohorts} cohorts but" + " found {len(animal_model_instance.cohorts)}" + else: + # For iteroparous organisms, the parent survives and the offspring is added + assert ( + len(animal_model_instance.cohorts) == initial_num_cohorts + 1 + ), f"Expected {initial_num_cohorts + 1} cohorts but" + " found {len(animal_model_instance.cohorts)}" + + # Get the offspring cohort (assuming it was added correctly) + offspring_cohort = list(animal_model_instance.cohorts.values())[-1] + + # Validate the attributes of the offspring cohort + assert ( + offspring_cohort.functional_group.name + == parent_cohort.functional_group.name + ) + assert ( + offspring_cohort.mass_current == parent_cohort.functional_group.birth_mass + ) + assert offspring_cohort.individuals == expected_offspring + + def test_forage_community( + self, + animal_model_instance, + herbivore_cohort_instance, + predator_cohort_instance, + mocker, + ): + """Test that forage_cohort is called correctly.""" + from virtual_ecosystem.models.animal.animal_traits import DietType -def test_calculate_density_for_cohort(prepared_animal_model_instance, mocker): - """Test the calculate_density_for_cohort method.""" + # Mock the methods for herbivore and predator cohorts using the mocker fixture + mock_forage_herbivore = mocker.Mock() + mock_forage_predator = mocker.Mock() + mock_get_excrement_pools_herbivore = mocker.Mock( + return_value=["excrement_pools_herbivore"] + ) + mock_get_excrement_pools_predator = mocker.Mock( + return_value=["excrement_pools_predator"] + ) + mock_get_plant_resources = mocker.Mock(return_value=["plant_resources"]) + mock_get_prey = mocker.Mock(return_value=["prey"]) - mock_cohort = mocker.MagicMock() - mock_cohort.individuals = 100 # Example number of individuals + # Set up herbivore cohort + herbivore_cohort_instance.functional_group.diet = DietType.HERBIVORE + mocker.patch.object( + herbivore_cohort_instance, "get_plant_resources", mock_get_plant_resources + ) + mocker.patch.object( + herbivore_cohort_instance, "get_prey", mocker.Mock() + ) # Should not be called for herbivores + mocker.patch.object( + herbivore_cohort_instance, + "get_excrement_pools", + mock_get_excrement_pools_herbivore, + ) + mocker.patch.object( + herbivore_cohort_instance, "forage_cohort", mock_forage_herbivore + ) - # Set a known community area in the model's data.grid.cell_area - prepared_animal_model_instance.data.grid.cell_area = 2000 # Example area in m2 + # Set up predator cohort + predator_cohort_instance.functional_group.diet = DietType.CARNIVORE + mocker.patch.object( + predator_cohort_instance, "get_plant_resources", mocker.Mock() + ) # Should not be called for predators + mocker.patch.object(predator_cohort_instance, "get_prey", mock_get_prey) + mocker.patch.object( + predator_cohort_instance, + "get_excrement_pools", + mock_get_excrement_pools_predator, + ) + mocker.patch.object( + predator_cohort_instance, "forage_cohort", mock_forage_predator + ) - # Expected density = individuals / area - expected_density = ( - mock_cohort.individuals / prepared_animal_model_instance.data.grid.cell_area - ) + # Add cohorts to the animal_model_instance + animal_model_instance.cohorts = { + "herbivore": herbivore_cohort_instance, + "predator": predator_cohort_instance, + } - # Calculate density using the method under test - calculated_density = prepared_animal_model_instance.calculate_density_for_cohort( - mock_cohort - ) + # Run the forage_community method + animal_model_instance.forage_community() - # Assert the calculated density matches the expected density - assert calculated_density == pytest.approx(expected_density), ( - f"Calculated density ({calculated_density}) " - f"did not match expected density ({expected_density})." - ) + # Verify that herbivores forage plant resources and not animal prey + mock_get_plant_resources.assert_called_once_with( + animal_model_instance.plant_resources + ) + herbivore_cohort_instance.get_prey.assert_not_called() + mock_forage_herbivore.assert_called_once_with( + plant_list=["plant_resources"], + animal_list=[], + excrement_pools=["excrement_pools_herbivore"], + carcass_pools=animal_model_instance.carcass_pools, + herbivory_waste_pools=animal_model_instance.leaf_waste_pools, + ) + + # Verify that predators forage prey and not plant resources + mock_get_prey.assert_called_once_with(animal_model_instance.communities) + predator_cohort_instance.get_plant_resources.assert_not_called() + mock_forage_predator.assert_called_once_with( + plant_list=[], + animal_list=["prey"], + excrement_pools=["excrement_pools_predator"], + carcass_pools=animal_model_instance.carcass_pools, + herbivory_waste_pools=animal_model_instance.leaf_waste_pools, + ) + + def test_metabolize_community( + self, animal_model_instance, dummy_animal_data, mocker + ): + """Test metabolize_community using real data from fixture.""" + + from numpy import timedelta64 + + # Assign the data from the fixture to the animal model + animal_model_instance.data = dummy_animal_data + air_temperature_data = dummy_animal_data["air_temperature"] + + print(air_temperature_data.shape) + + # Create mock cohorts and their behaviors + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Mock return values for metabolize and respire + mock_cohort_1.metabolize.return_value = ( + 10.0 # Metabolic waste mass for cohort 1 + ) + mock_cohort_2.metabolize.return_value = ( + 15.0 # Metabolic waste mass for cohort 2 + ) + mock_cohort_1.respire.return_value = 5.0 # Carbonaceous waste for cohort 1 + mock_cohort_2.respire.return_value = 8.0 # Carbonaceous waste for cohort 2 + + # Setup the community and excrement pools in the animal model + animal_model_instance.communities = { + 1: [mock_cohort_1, mock_cohort_2], # Community in cell 1 with two cohorts + 2: [], # Empty community in cell 2 + } + animal_model_instance.excrement_pools = { + 1: "excrement_pool_1", + 2: "excrement_pool_2", + } + + # Run the metabolize_community method + dt = timedelta64(1, "D") # 1 day as the time delta + animal_model_instance.metabolize_community(dt) + + # Assertions for the first cohort in cell 1 + mock_cohort_1.metabolize.assert_called_once_with( + 16.145945, dt + ) # Temperature for cell 1 from the fixture (25.0) + mock_cohort_1.respire.assert_called_once_with( + 10.0 + ) # Metabolic waste returned by metabolize + mock_cohort_1.excrete.assert_called_once_with(10.0, "excrement_pool_1") + + # Assertions for the second cohort in cell 1 + mock_cohort_2.metabolize.assert_called_once_with( + 16.145945, dt + ) # Temperature for cell 1 from the fixture (25.0) + mock_cohort_2.respire.assert_called_once_with( + 15.0 + ) # Metabolic waste returned by metabolize + mock_cohort_2.excrete.assert_called_once_with(15.0, "excrement_pool_1") + + # Assert total animal respiration was updated for cell 1 + total_animal_respiration = animal_model_instance.data[ + "total_animal_respiration" + ] + assert total_animal_respiration.loc[{"cell_id": 1}] == 13.0 # 5.0 + 8.0 + + # Ensure no cohort methods were called for the empty community in cell 2 + mock_cohort_1.reset_mock() + mock_cohort_2.reset_mock() + mock_cohort_1.metabolize.assert_not_called() + mock_cohort_2.metabolize.assert_not_called() + + def test_increase_age_community(self, animal_model_instance, mocker): + """Test increase_age.""" + + from numpy import timedelta64 + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + } + + # Define the time delta + dt = timedelta64(10, "D") # 10 days + + # Run the increase_age_community method + animal_model_instance.increase_age_community(dt) + + # Assert that increase_age was called with the correct time delta + mock_cohort_1.increase_age.assert_called_once_with(dt) + mock_cohort_2.increase_age.assert_called_once_with(dt) + + def test_inflict_non_predation_mortality_community( + self, animal_model_instance, mocker + ): + """Test inflict_non_predation_mortality_community.""" + + from numpy import timedelta64 + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + } + + # Mock return values for cohort methods + mock_cohort_1.get_carcass_pools.return_value = "carcass_pool_1" + mock_cohort_2.get_carcass_pools.return_value = "carcass_pool_2" + + # Define the number of individuals + mock_cohort_1.individuals = 100 + mock_cohort_2.individuals = 0 # This cohort should be marked as dead + + # Mock the remove_dead_cohort method + mock_remove_dead_cohort = mocker.patch.object( + animal_model_instance, "remove_dead_cohort" + ) + + # Define the time delta + dt = timedelta64(10, "D") # 10 days + + # Run the inflict_non_predation_mortality_community method + animal_model_instance.inflict_non_predation_mortality_community(dt) + + # Calculate the number of days from dt + number_of_days = float(dt / timedelta64(1, "D")) + + # Assert that inflict_non_predation_mortality called with the correct arguments + mock_cohort_1.inflict_non_predation_mortality.assert_called_once_with( + number_of_days, "carcass_pool_1" + ) + mock_cohort_2.inflict_non_predation_mortality.assert_called_once_with( + number_of_days, "carcass_pool_2" + ) + + # Assert that remove_dead_cohort was called for the cohort with zero individuals + mock_remove_dead_cohort.assert_called_once_with(mock_cohort_2) + + # Ensure that the cohort with zero individuals is marked as dead + assert mock_cohort_2.is_alive is False + + # Ensure that the cohort with individuals is not marked as dead + assert mock_cohort_1.is_alive is not False + + def test_metamorphose( + self, + animal_model_instance, + caterpillar_cohort_instance, + ): + """Test metamorphose. + + TODO: add broader assertions + + + """ + + from math import ceil + + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Clear the cohorts list to ensure it is empty + animal_model_instance.cohorts = {} + + # Add the caterpillar cohort to the animal model's cohorts + animal_model_instance.cohorts[caterpillar_cohort_instance.id] = ( + caterpillar_cohort_instance + ) + + # Set the larval cohort (caterpillar) properties + caterpillar_cohort_instance.functional_group.offspring_functional_group = ( + "butterfly" + ) + + initial_individuals = 100 + caterpillar_cohort_instance.individuals = initial_individuals + + # Calculate the expected number of individuals lost due to mortality + number_dead = ceil( + initial_individuals + * caterpillar_cohort_instance.constants.metamorph_mortality + ) + + # Set up functional groups in the animal model instance + butterfly_functional_group = get_functional_group_by_name( + animal_model_instance.functional_groups, + caterpillar_cohort_instance.functional_group.offspring_functional_group, + ) + + # Ensure the butterfly functional group is found + assert ( + butterfly_functional_group is not None + ), "Butterfly functional group not found" + + # Run the metamorphose method on the caterpillar cohort + animal_model_instance.metamorphose(caterpillar_cohort_instance) + + # Assert that the number of individuals in the caterpillar cohort was reduced + assert ( + caterpillar_cohort_instance.individuals == initial_individuals - number_dead + ), "Caterpillar cohort's individuals count is incorrect after metamorphosis" + + # Assert that a new butterfly cohort was created from the caterpillar + adult_cohort = next( + ( + cohort + for cohort in animal_model_instance.cohorts.values() + if cohort.functional_group == butterfly_functional_group + ), + None, + ) + assert adult_cohort is not None, "Butterfly cohort was not created" + + # Assert that the number of individuals in the butterfly cohort is correct + assert ( + adult_cohort.individuals == caterpillar_cohort_instance.individuals + ), "Butterfly cohort's individuals count does not match the expected value" + + # Assert that the caterpillar cohort is marked as dead and removed + assert ( + not caterpillar_cohort_instance.is_alive + ), "Caterpillar cohort should be marked as dead" + assert ( + caterpillar_cohort_instance not in animal_model_instance.cohorts.values() + ), "Caterpillar cohort should be removed from the model" + + def test_metamorphose_community(self, animal_model_instance, mocker): + """Test metamorphose_community.""" + + from virtual_ecosystem.models.animal.animal_traits import DevelopmentType + + # Create mock cohorts + mock_cohort_1 = mocker.Mock() + mock_cohort_2 = mocker.Mock() + mock_cohort_3 = mocker.Mock() + + # Setup the animal model with mock cohorts + animal_model_instance.cohorts = { + "cohort_1": mock_cohort_1, + "cohort_2": mock_cohort_2, + "cohort_3": mock_cohort_3, + } + + # Set the properties for each cohort + mock_cohort_1.functional_group.development_type = DevelopmentType.INDIRECT + mock_cohort_1.mass_current = 20.0 + mock_cohort_1.functional_group.adult_mass = 15.0 # Ready for metamorphosis + + mock_cohort_2.functional_group.development_type = DevelopmentType.INDIRECT + mock_cohort_2.mass_current = 10.0 + mock_cohort_2.functional_group.adult_mass = 15.0 # Not ready for metamorphosis + + mock_cohort_3.functional_group.development_type = DevelopmentType.DIRECT + mock_cohort_3.mass_current = 20.0 + mock_cohort_3.functional_group.adult_mass = ( + 15.0 # Direct development, should not metamorphose + ) + + # Mock the metamorphose method + mock_metamorphose = mocker.patch.object(animal_model_instance, "metamorphose") + + # Run the metamorphose_community method + animal_model_instance.metamorphose_community() + + # Assert that metamorphose was called only for cohort that is ready and indirect + mock_metamorphose.assert_called_once_with(mock_cohort_1) + + # Assert that the other cohorts did not trigger metamorphosis + mock_metamorphose.assert_called_once() # Ensure it was called exactly once diff --git a/tests/models/animals/test_decay.py b/tests/models/animals/test_decay.py index e1f56856b..94dcf204f 100644 --- a/tests/models/animals/test_decay.py +++ b/tests/models/animals/test_decay.py @@ -3,12 +3,8 @@ This module tests the functionality of decay.py """ -from logging import ERROR - import pytest -from tests.conftest import log_check - class TestCarcassPool: """Test the CarcassPool class.""" @@ -151,36 +147,3 @@ def test_get_eaten(self, litter_pool_instance, herbivore_cohort_instance): assert initial_c_p_ratio == pytest.approx( litter_pool_instance.c_p_ratio[cell_id] ) - - -class TestHerbivoryWaste: - """Test the HerbivoryWaste class.""" - - def test_initialization(self): - """Testing initialization of HerbivoryWaste.""" - from virtual_ecosystem.models.animal.decay import HerbivoryWaste - - dead_leaves = HerbivoryWaste(plant_matter_type="leaf") - # Test that function to calculate stored carbon works as expected - assert pytest.approx(dead_leaves.plant_matter_type) == "leaf" - assert pytest.approx(dead_leaves.mass_current) == 0.0 - assert pytest.approx(dead_leaves.c_n_ratio) == 20.0 - assert pytest.approx(dead_leaves.c_p_ratio) == 150.0 - assert pytest.approx(dead_leaves.lignin_proportion) == 0.25 - - def test_bad_initialization(self, caplog): - """Testing that initialization of HerbivoryWaste fails sensibly.""" - from virtual_ecosystem.models.animal.decay import HerbivoryWaste - - expected_log = ( - ( - ERROR, - "fig not a valid form of herbivory waste, valid forms are as follows: ", - ), - ) - - with pytest.raises(ValueError): - HerbivoryWaste(plant_matter_type="fig") - - # Check the error reports - log_check(caplog, expected_log) diff --git a/tests/models/animals/test_plant_resources.py b/tests/models/animals/test_plant_resources.py index c32ac74df..d3957154e 100644 --- a/tests/models/animals/test_plant_resources.py +++ b/tests/models/animals/test_plant_resources.py @@ -1,17 +1,18 @@ """Test module for plant_resources.py.""" +import pytest + class TestPlantResources: """Test Plant class.""" def test_get_eaten(self, plant_instance, herbivore_cohort_instance): """Test the get_eaten method for PlantResources.""" - import pytest - consumed_mass = 50.0 # Define a mass to be consumed for the test initial_mass_current = plant_instance.mass_current - actual_mass_gain, actual_excess_mass = plant_instance.get_eaten( + # Call the method + actual_mass_gain, excess_mass = plant_instance.get_eaten( consumed_mass, herbivore_cohort_instance ) @@ -20,8 +21,8 @@ def test_get_eaten(self, plant_instance, herbivore_cohort_instance): initial_mass_current - consumed_mass ), "Plant mass should be reduced by the consumed amount." - # Check if the actual mass gain matches the expected value after - # efficiency adjustments + # Check if the actual mass gain matches the expected value after efficiency + # adjustments expected_mass_gain = ( consumed_mass * herbivore_cohort_instance.functional_group.mechanical_efficiency @@ -31,10 +32,10 @@ def test_get_eaten(self, plant_instance, herbivore_cohort_instance): expected_mass_gain ), "Actual mass gain should match expected value after efficiency adjustments." - # Check if the excess mass has been correctly added to the excrement pool - excess_mass = consumed_mass * ( + # Check if the excess mass has been calculated correctly + expected_excess_mass = consumed_mass * ( 1 - herbivore_cohort_instance.functional_group.mechanical_efficiency ) - assert actual_excess_mass == pytest.approx( - excess_mass - ), "Excrement pool energy should increase by energy value of the excess mass." + assert excess_mass == pytest.approx( + expected_excess_mass + ), "Excess mass should match the expected value based on mechanical efficiency." diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index ded76ab43..8d6e3195a 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -1,30 +1,25 @@ -"""The ''animal'' module provides animal module functionality. - -Notes: -- assume each grid = 1 km2 -- assume each tick = 1 day (28800s) -- damuth ~ 4.23*mass**(-3/4) indiv / km2 -""" +"""The ''animal'' module provides animal module functionality.""" from __future__ import annotations -from collections.abc import Sequence from math import ceil, exp, sqrt +from uuid import uuid4 from numpy import timedelta64 import virtual_ecosystem.models.animal.scaling_functions as sf +from virtual_ecosystem.core.grid import Grid from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_traits import DietType from virtual_ecosystem.models.animal.constants import AnimalConsts from virtual_ecosystem.models.animal.decay import ( CarcassPool, + ExcrementPool, HerbivoryWaste, find_decay_consumed_split, ) from virtual_ecosystem.models.animal.functional_group import FunctionalGroup -from virtual_ecosystem.models.animal.plant_resources import PlantResources -from virtual_ecosystem.models.animal.protocols import Consumer, DecayPool +from virtual_ecosystem.models.animal.protocols import Resource class AnimalCohort: @@ -36,6 +31,8 @@ def __init__( mass: float, age: float, individuals: int, + centroid_key: int, + grid: Grid, constants: AnimalConsts = AnimalConsts(), ) -> None: if age < 0: @@ -57,8 +54,14 @@ def __init__( """The age of the animal cohort [days].""" self.individuals = individuals """The number of individuals in this cohort.""" + self.centroid_key = centroid_key + """The centroid key of the cohort's territory.""" + self.grid = grid + """The the grid structure of the simulation.""" self.constants = constants """Animal constants.""" + self.id = uuid4() + """A unique identifier for the cohort.""" self.damuth_density: int = sf.damuths_law( self.functional_group.adult_mass, self.functional_group.damuths_law_terms ) @@ -73,12 +76,22 @@ def __init__( """The amount of time [days] since reaching adult body-mass.""" self.reproductive_mass: float = 0.0 """The pool of biomass from which the material of reproduction is drawn.""" - self.prey_groups = sf.prey_group_selection( + self.prey_groups: dict[str, tuple[float, float]] = sf.prey_group_selection( self.functional_group.diet, self.functional_group.adult_mass, self.functional_group.prey_scaling, ) """The identification of useable food resources.""" + self.territory_size = sf.territory_size(self.functional_group.adult_mass) + """The size in hectares of the animal cohorts territory.""" + self.occupancy_proportion: float = 1.0 / self.territory_size + """The proportion of the cohort that is within a territorial given grid cell.""" + self._initialize_territory(centroid_key) + """Initialize the territory using the centroid grid key.""" + self.territory: list[int] + """The list of grid cells currently occupied by the cohort.""" + # TODO - In future this should be parameterised using a constants dataclass, but + # this hasn't yet been implemented for the animal model self.decay_fraction_excrement: float = find_decay_consumed_split( microbial_decay_rate=self.constants.decay_rate_excrement, animal_scavenging_rate=self.constants.scavenging_rate_excrement, @@ -90,6 +103,54 @@ def __init__( ) """The fraction of carcass biomass which decays before it gets consumed.""" + def get_territory_cells(self, centroid_key: int) -> list[int]: + """This calls bfs_territory to determine the scope of the territory. + + TODO: local import of bfs_territory is temporary while deciding whether to keep + animal_territory.py + + Args: + centroid_key: The central grid cell key of the territory. + + """ + # Each grid cell is 1 hectare, territory size in grids is the same as hectares + target_cell_number = int(self.territory_size) + + # Perform BFS to determine the territory cells + territory_cells = sf.bfs_territory( + centroid_key, + target_cell_number, + self.grid.cell_nx, + self.grid.cell_ny, + ) + + return territory_cells + + def _initialize_territory( + self, + centroid_key: int, + ) -> None: + """This initializes the territory occupied by the cohort. + + TODO: local import of AnimalTerritory is temporary while deciding whether to + keep the class + + Args: + centroid_key: The grid cell key anchoring the territory. + """ + + self.territory = self.get_territory_cells(centroid_key) + + def update_territory(self, new_grid_cell_keys: list[int]) -> None: + """Update territory details at initialization and after migration. + + Args: + new_grid_cell_keys: The new list of grid cell keys the territory occupies. + + """ + + self.territory = new_grid_cell_keys + def metabolize(self, temperature: float, dt: timedelta64) -> float: """The function to reduce body mass through metabolism. @@ -132,33 +193,53 @@ def metabolize(self, temperature: float, dt: timedelta64) -> float: # in data object return actual_mass_metabolized * self.individuals - def excrete(self, excreta_mass: float, excrement_pool: DecayPool) -> None: - """Transfers nitrogenous metabolic wastes to the excrement pool. - - This method will not be fully implemented until the stoichiometric rework. All - current metabolic wastes are carbonaceous and so all this does is provide a link - joining metabolism to a soil pool for later use. + def excrete( + self, excreta_mass: float, excrement_pools: list[ExcrementPool] + ) -> None: + """Transfers metabolic wastes to the excrement pool. - TODO: Update with sensible (rather than hardcoded) stoichiometry + This method handles nitrogenous and carbonaceous wastes, split between + scavengeable and decomposed pools. Pending rework of stoichiometric + calculations. Args: - excreta_mass: The total mass of carbonaceous wastes excreted by the cohort. - excrement_pool: The pool of wastes to which the excreted nitrogenous wastes - flow. - + excreta_mass: The total mass of wastes excreted by the cohort. + excrement_pools: The pools of waste to which the excreted wastes flow. """ - excrement_pool.decomposed_nitrogen += ( - excreta_mass * self.constants.nitrogen_excreta_proportion + number_communities = len(excrement_pools) + + # Calculate excreta mass per community and proportionate nitrogen flow + excreta_mass_per_community = excreta_mass / number_communities + nitrogen_mass_per_community = ( + excreta_mass_per_community * self.constants.nitrogen_excreta_proportion ) - # TODO - Carbon and phosphorus flows are just hardcoded fractions of the - # nitrogen flow. This needs to be changed when proper animal stoichiometry is - # done. - excrement_pool.decomposed_carbon += ( - 0.5 * excreta_mass * self.constants.nitrogen_excreta_proportion + + # Calculate scavengeable and decomposed nitrogen + scavengeable_nitrogen_per_community = ( + 1 - self.decay_fraction_excrement + ) * nitrogen_mass_per_community + decomposed_nitrogen_per_community = ( + self.decay_fraction_excrement * nitrogen_mass_per_community ) - excrement_pool.decomposed_phosphorus += ( - 0.01 * excreta_mass * self.constants.nitrogen_excreta_proportion + + # Carbon and phosphorus are fractions of nitrogen per community + scavengeable_carbon_per_community = 0.5 * scavengeable_nitrogen_per_community + decomposed_carbon_per_community = 0.5 * decomposed_nitrogen_per_community + scavengeable_phosphorus_per_community = ( + 0.01 * scavengeable_nitrogen_per_community ) + decomposed_phosphorus_per_community = 0.01 * decomposed_nitrogen_per_community + + for excrement_pool in excrement_pools: + # Assign calculated nitrogen, carbon, and phosphorus to the pool + excrement_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_community + excrement_pool.decomposed_nitrogen += decomposed_nitrogen_per_community + excrement_pool.scavengeable_carbon += scavengeable_carbon_per_community + excrement_pool.decomposed_carbon += decomposed_carbon_per_community + excrement_pool.scavengeable_phosphorus += ( + scavengeable_phosphorus_per_community + ) + excrement_pool.decomposed_phosphorus += decomposed_phosphorus_per_community def respire(self, excreta_mass: float) -> float: """Transfers carbonaceous metabolic wastes to the atmosphere. @@ -181,54 +262,81 @@ def respire(self, excreta_mass: float) -> float: def defecate( self, - excrement_pool: DecayPool, + excrement_pools: list[ExcrementPool], mass_consumed: float, ) -> None: - """Transfer waste mass from an animal cohort to the excrement pool. + """Transfer waste mass from an animal cohort to the excrement pools. - Waste mass is transferred to the excrement pool, split between a decomposed and - a scavengable compartment. Carbon, nitrogen and phosphorus are all transferred. - An assumption here is that the stoichiometric ratios of the flows to each - compartment are equal, i.e. the nutrient split between compartments is - calculated identically to the carbon split. + Waste mass is transferred to the excrement pool(s), split between decomposed and + scavengable compartments. Carbon, nitrogen, and phosphorus are transferred + according to stoichiometric ratios. Mass is distributed over multiple excrement + pools if provided. TODO: Needs to be reworked to use carbon mass rather than total mass. - TODO: update for current conversion efficiency - TODO: Update with stoichiometry + TODO: Update with current conversion efficiency and stoichiometry. Args: - excrement_pool: The local ExcrementSoil pool in which waste is deposited. + excrement_pools: The ExcrementPool objects in the cohort's territory in + which waste is deposited. mass_consumed: The amount of mass flowing through cohort digestion. """ - # Find total waste mass, the total amount of waste is then found by the - # average cohort member * number individuals. - waste_carbon = mass_consumed * self.functional_group.conversion_efficiency - # TODO - replace this with sensible stoichiometry - waste_nitrogen = 0.1 * waste_carbon - waste_phosphorus = 0.01 * waste_carbon - - # This total waste is then split between decay and scavengeable excrement - excrement_pool.scavengeable_carbon += ( - (1 - self.decay_fraction_excrement) * waste_carbon * self.individuals - ) - excrement_pool.decomposed_carbon += ( - self.decay_fraction_excrement * waste_carbon * self.individuals - ) - # Key assumption here is that the split between scavengable and decomposed pools - # has equal stochiometries - excrement_pool.scavengeable_nitrogen += ( - (1 - self.decay_fraction_excrement) * waste_nitrogen * self.individuals + number_communities = len(excrement_pools) + + # Calculate the total waste mass, which is the mass consumed times conversion + # efficiency + total_waste_mass = ( + mass_consumed + * self.functional_group.conversion_efficiency + * self.individuals ) - excrement_pool.decomposed_nitrogen += ( - self.decay_fraction_excrement * waste_nitrogen * self.individuals + + # Split the waste mass proportionally among communities + waste_mass_per_community = total_waste_mass / number_communities + + # Calculate waste for carbon, nitrogen, and phosphorus using current + # stoichiometry + waste_carbon_per_community = waste_mass_per_community + waste_nitrogen_per_community = 0.1 * waste_carbon_per_community + waste_phosphorus_per_community = 0.01 * waste_carbon_per_community + + # Pre-calculate the scavengeable and decomposed fractions for each nutrient + scavengeable_carbon_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_carbon_per_community + decomposed_carbon_per_community = ( + self.decay_fraction_excrement * waste_carbon_per_community ) - excrement_pool.scavengeable_phosphorus += ( - (1 - self.decay_fraction_excrement) * waste_phosphorus * self.individuals + + scavengeable_nitrogen_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_nitrogen_per_community + decomposed_nitrogen_per_community = ( + self.decay_fraction_excrement * waste_nitrogen_per_community ) - excrement_pool.decomposed_phosphorus += ( - self.decay_fraction_excrement * waste_phosphorus * self.individuals + + scavengeable_phosphorus_per_community = ( + 1 - self.decay_fraction_excrement + ) * waste_phosphorus_per_community + decomposed_phosphorus_per_community = ( + self.decay_fraction_excrement * waste_phosphorus_per_community ) + # Distribute waste across each excrement pool + for excrement_pool in excrement_pools: + # Update carbon pools + excrement_pool.scavengeable_carbon += scavengeable_carbon_per_community + excrement_pool.decomposed_carbon += decomposed_carbon_per_community + + # Update nitrogen pools + excrement_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_community + excrement_pool.decomposed_nitrogen += decomposed_nitrogen_per_community + + # Update phosphorus pools + excrement_pool.scavengeable_phosphorus += ( + scavengeable_phosphorus_per_community + ) + excrement_pool.decomposed_phosphorus += decomposed_phosphorus_per_community + def increase_age(self, dt: timedelta64) -> None: """The function to modify cohort age as time passes and flag maturity. @@ -250,7 +358,9 @@ def increase_age(self, dt: timedelta64) -> None: self.is_mature = True self.time_to_maturity = self.age - def die_individual(self, number_dead: int, carcass_pool: CarcassPool) -> None: + def die_individual( + self, number_dead: int, carcass_pools: list[CarcassPool] + ) -> None: """The function to reduce the number of individuals in the cohort through death. Currently, all cohorts are crafted as single km2 grid cohorts. This means that @@ -258,108 +368,106 @@ def die_individual(self, number_dead: int, carcass_pool: CarcassPool) -> None: are made to capture large body size and multi-grid occupancy, this will be updated. - Mass of dead individuals is transferred to the carcass pool, split between a - decomposed and a scavengable compartment. Carbon, nitrogen and phosphorus are - all transferred. An assumption here is that the stoichiometric ratios of the - flows to each compartment are equal, i.e. the nutrient split between - compartments is calculated identically to the carbon split. + Currently, this function is in an inbetween state where mass is removed from + the animal cohort but it is recieved by the litter pool as energy. This will be + fixed once the litter pools are updated for mass. - TODO: This needs to take in carbon mass not total mass - TODO: This needs to use proper stochiometry + TODO: Rework after update litter pools for mass Args: number_dead: The number of individuals by which to decrease the population count. - carcass_pool: The resident pool of animal carcasses to which the dead + carcass_pools: The resident pool of animal carcasses to which the dead individuals are delivered. """ self.individuals -= number_dead # Find total mass contained in the carcasses - # TODO - This mass needs to be total mass not carbon mass carcass_mass = number_dead * self.mass_current - # TODO - Carcass stochiometries are found using a hard coded ratio, this needs - # to go once stoichiometry is properly implemented - carcass_mass_nitrogen = 0.1 * carcass_mass - carcass_mass_phosphorus = 0.01 * carcass_mass - - # Split this mass between carcass decay, and scavengeable carcasses - carcass_pool.scavengeable_carbon += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass - carcass_pool.decomposed_carbon += self.decay_fraction_carcasses * carcass_mass - carcass_pool.scavengeable_nitrogen += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass_nitrogen - carcass_pool.decomposed_nitrogen += ( - self.decay_fraction_carcasses * carcass_mass_nitrogen - ) - carcass_pool.scavengeable_phosphorus += ( - 1 - self.decay_fraction_carcasses - ) * carcass_mass_phosphorus - carcass_pool.decomposed_phosphorus += ( - self.decay_fraction_carcasses * carcass_mass_phosphorus - ) + self.update_carcass_pool(carcass_mass, carcass_pools) - def update_carcass_pool(self, carcass_mass: float, carcass_pool: DecayPool) -> None: - """Updates the carcass pool based on consumed mass and predator's efficiency. + def update_carcass_pool( + self, carcass_mass: float, carcass_pools: list[CarcassPool] + ) -> None: + """Updates the carcass pools after deaths. - Mass of dead individuals is transferred to the carcass pool, split between a - decomposed and a scavengable compartment. Carbon, nitrogen and phosphorus are - all transferred. An assumption here is that the stoichiometric ratios of the - flows to each compartment are equal, i.e. the nutrient split between - compartments is calculated identically to the carbon split. + Carcass mass is transferred to the carcass pools, split between a decomposed and + a scavengeable compartment. Carbon, nitrogen, and phosphorus are all transferred + according to stoichiometric ratios. - TODO: This needs to take in carbon mass not total mass - TODO: This needs to use proper stochiometry + TODO: Update to handle proper carbon mass rather than total mass. + TODO: Use dynamic stoichiometry once implemented. Args: carcass_mass: The total mass consumed from the prey cohort. - carcass_pool: The pool to which remains of eaten individuals are delivered. + carcass_pools: The pools to which remains of eaten individuals are + delivered. """ + number_carcass_pools = len(carcass_pools) + + # Split carcass mass per pool + carcass_mass_per_pool = carcass_mass / number_carcass_pools - # TODO - Carcass stochiometries are found using a hard coded ratio, this needs - # to go once stoichiometry is properly implemented - carcass_mass_nitrogen = 0.1 * carcass_mass - carcass_mass_phosphorus = 0.01 * carcass_mass + # Calculate stoichiometric proportions for nitrogen and phosphorus + carcass_mass_nitrogen_per_pool = 0.1 * carcass_mass_per_pool + carcass_mass_phosphorus_per_pool = 0.01 * carcass_mass_per_pool - # TODO - This also needs to be updated to carbon mass rather than total mass - # terms - # Update the carcass pool with the remainder - carcass_pool.scavengeable_carbon += ( + # Pre-calculate scavengeable and decomposed fractions for carbon, nitrogen, + # and phosphorus + scavengeable_carbon_per_pool = ( 1 - self.decay_fraction_carcasses - ) * carcass_mass - carcass_pool.decomposed_carbon += self.decay_fraction_carcasses * carcass_mass - carcass_pool.scavengeable_nitrogen += ( + ) * carcass_mass_per_pool + decomposed_carbon_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_per_pool + ) + + scavengeable_nitrogen_per_pool = ( 1 - self.decay_fraction_carcasses - ) * carcass_mass_nitrogen - carcass_pool.decomposed_nitrogen += ( - self.decay_fraction_carcasses * carcass_mass_nitrogen + ) * carcass_mass_nitrogen_per_pool + decomposed_nitrogen_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_nitrogen_per_pool ) - carcass_pool.scavengeable_phosphorus += ( + + scavengeable_phosphorus_per_pool = ( 1 - self.decay_fraction_carcasses - ) * carcass_mass_phosphorus - carcass_pool.decomposed_phosphorus += ( - self.decay_fraction_carcasses * carcass_mass_phosphorus + ) * carcass_mass_phosphorus_per_pool + decomposed_phosphorus_per_pool = ( + self.decay_fraction_carcasses * carcass_mass_phosphorus_per_pool ) + # Distribute carcass mass across the carcass pools + for carcass_pool in carcass_pools: + # Update carbon pools + carcass_pool.scavengeable_carbon += scavengeable_carbon_per_pool + carcass_pool.decomposed_carbon += decomposed_carbon_per_pool + + # Update nitrogen pools + carcass_pool.scavengeable_nitrogen += scavengeable_nitrogen_per_pool + carcass_pool.decomposed_nitrogen += decomposed_nitrogen_per_pool + + # Update phosphorus pools + carcass_pool.scavengeable_phosphorus += scavengeable_phosphorus_per_pool + carcass_pool.decomposed_phosphorus += decomposed_phosphorus_per_pool + def get_eaten( self, potential_consumed_mass: float, - predator: Consumer, - carcass_pool: DecayPool, + predator: AnimalCohort, + carcass_pools: dict[int, list[CarcassPool]], ) -> float: """Removes individuals according to mass demands of a predation event. It finds the smallest whole number of prey required to satisfy the predators mass demands and caps at then caps it at the available population. + Args: potential_consumed_mass: The mass intended to be consumed by the predator. predator: The predator consuming the cohort. - carcass_pool: The pool to which remains of eaten individuals are delivered. + carcass_pools: The pools to which remains of eaten individuals are + delivered. Returns: The actual mass consumed by the predator, closely matching consumed_mass. @@ -385,8 +493,17 @@ def get_eaten( # Update the number of individuals in the prey cohort self.individuals -= actual_individuals_killed + # set cohort to not alive if all the individuals are dead + if self.individuals <= 0: + self.is_alive = False + + # Find the intersection of prey and predator territories + intersection_carcass_pools = self.find_intersecting_carcass_pools( + predator.territory, carcass_pools + ) + # Update the carcass pool with carcass mass - self.update_carcass_pool(carcass_mass, carcass_pool) + self.update_carcass_pool(carcass_mass, intersection_carcass_pools) return actual_mass_consumed @@ -406,7 +523,7 @@ def calculate_alpha(self) -> float: return sf.alpha_i_k(self.constants.alpha_0_herb, self.mass_current) def calculate_potential_consumed_biomass( - self, target_plant: PlantResources, alpha: float + self, target_plant: Resource, alpha: float ) -> float: """Calculate potential consumed biomass for the target plant. @@ -430,7 +547,7 @@ def calculate_potential_consumed_biomass( return sf.k_i_k(alpha, phi, target_plant.mass_current, A_cell) def calculate_total_handling_time_for_herbivory( - self, plant_list: Sequence[PlantResources], alpha: float + self, plant_list: list[Resource], alpha: float ) -> float: """Calculate total handling time across all plant resources. @@ -439,9 +556,10 @@ def calculate_total_handling_time_for_herbivory( the total handling time required by the cohort. TODO: give A_cell a grid size reference. + TODO: MGO - rework for territories Args: - plant_list: A sequence of plant resources available for consumption by the + plant_list: A list of plant resources available for consumption by the cohort. alpha: The search efficiency rate of the herbivore cohort. @@ -463,9 +581,7 @@ def calculate_total_handling_time_for_herbivory( for plant in plant_list ) - def F_i_k( - self, plant_list: Sequence[PlantResources], target_plant: PlantResources - ) -> float: + def F_i_k(self, plant_list: list[Resource], target_plant: Resource) -> float: """Method to determine instantaneous herbivory rate on plant k. This method integrates the calculated search efficiency, potential consumed @@ -476,7 +592,7 @@ def F_i_k( TODO: update name Args: - plant_list: A sequence of plant resources available for consumption by the + plant_list: A list of plant resources available for consumption by the cohort. target_plant: The specific plant resource being targeted by the herbivore cohort for consumption. @@ -547,6 +663,7 @@ def calculate_potential_prey_consumed( """Calculate the potential number of prey consumed. TODO: give A_cell a grid size reference + TODO: MGO - rework for territories Args: alpha: the predation search rate @@ -575,12 +692,12 @@ def calculate_total_handling_time_for_predation(self) -> float: ) def F_i_j_individual( - self, animal_list: Sequence[AnimalCohort], target_cohort: AnimalCohort + self, animal_list: list[AnimalCohort], target_cohort: AnimalCohort ) -> float: """Method to determine instantaneous predation rate on cohort j. Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. target_cohort: The prey cohort from which mass will be consumed. @@ -600,7 +717,7 @@ def F_i_j_individual( return N_i * (k_target / (1 + total_handling_t)) * (1 / N_target) def calculate_consumed_mass_predation( - self, animal_list: Sequence[AnimalCohort], target_cohort: AnimalCohort + self, animal_list: list[AnimalCohort], target_cohort: AnimalCohort ) -> float: """Calculates the mass to be consumed from a prey cohort by the predator. @@ -611,7 +728,7 @@ def calculate_consumed_mass_predation( TODO: Replace delta_t with time step reference Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. target_cohort: The prey cohort from which mass will be consumed. @@ -635,19 +752,21 @@ def calculate_consumed_mass_predation( def delta_mass_predation( self, - animal_list: Sequence[AnimalCohort], - excrement_pool: DecayPool, - carcass_pool: CarcassPool, + animal_list: list[AnimalCohort], + excrement_pools: list[ExcrementPool], + carcass_pools: dict[int, list[CarcassPool]], ) -> float: """This method handles mass assimilation from predation. This is Madingley's delta_assimilation_mass_predation + TODO: rethink defecate location + Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. - excrement_pool: A pool representing the excrement in the grid cell. - carcass_pool: A pool representing the animal carcasses in the grid cell. + excrement_pools: The pools representing the excrement in the territory. + carcass_pools: The pools to which animal carcasses are delivered. Returns: The change in mass experienced by the predator. @@ -655,20 +774,24 @@ def delta_mass_predation( total_consumed_mass = 0.0 # Initialize the total consumed mass - for cohort in animal_list: + for prey_cohort in animal_list: # Calculate the mass to be consumed from this cohort - consumed_mass = self.calculate_consumed_mass_predation(animal_list, cohort) + consumed_mass = self.calculate_consumed_mass_predation( + animal_list, prey_cohort + ) # Call get_eaten on the prey cohort to update its mass and individuals - actual_consumed_mass = cohort.get_eaten(consumed_mass, self, carcass_pool) + actual_consumed_mass = prey_cohort.get_eaten( + consumed_mass, self, carcass_pools + ) # Update total mass gained by the predator total_consumed_mass += actual_consumed_mass # Process waste generated from predation, separate from herbivory b/c diff waste - self.defecate(excrement_pool, total_consumed_mass) + self.defecate(excrement_pools, total_consumed_mass) return total_consumed_mass def calculate_consumed_mass_herbivory( - self, plant_list: Sequence[PlantResources], target_plant: PlantResources + self, plant_list: list[Resource], target_plant: Resource ) -> float: """Calculates the mass to be consumed from a plant resource by the herbivore. @@ -679,7 +802,7 @@ def calculate_consumed_mass_herbivory( TODO: Replace delta_t with actual time step reference Args: - plant_list: A sequence of plant resources that can be consumed by the + plant_list: A list of plant resources that can be consumed by the herbivore. target_plant: The plant resource from which mass will be consumed. @@ -696,25 +819,26 @@ def calculate_consumed_mass_herbivory( def delta_mass_herbivory( self, - plant_list: Sequence[PlantResources], - excrement_pool: DecayPool, - plant_waste_pool: HerbivoryWaste, + plant_list: list[Resource], + excrement_pools: list[ExcrementPool], + herbivory_waste_pools: dict[int, HerbivoryWaste], ) -> float: """This method handles mass assimilation from herbivory. - TODO: update name + TODO: rethink defecate location TODO: At present this just takes a single herbivory waste pool (for leaves), this probably should change to be a list of waste pools once herbivory for other plant tissues is added. + TODO: update name Args: - plant_list: A sequence of plant resources available for herbivory. - excrement_pool: A pool representing the excrement in the grid cell. - plant_waste_pool: Waste pool for plant biomass (at this point just leaves) - that gets removed as part of herbivory but not actually consumed. + plant_list: A list of plant resources available for herbivory. + excrement_pools: The pools representing the excrement in the territory. + herbivory_waste_pools: Waste pools for plant biomass (at this point just + leaves) that gets removed as part of herbivory but not actually consumed. Returns: - The total plant mass consumed by the animal cohort in g. + A float of the total plant mass consumed by the animal cohort in g. """ total_consumed_mass = 0.0 # Initialize the total consumed mass @@ -726,28 +850,30 @@ def delta_mass_herbivory( actual_consumed_mass, excess_mass = plant.get_eaten(consumed_mass, self) # Update total mass gained by the herbivore total_consumed_mass += actual_consumed_mass - plant_waste_pool.mass_current += excess_mass + herbivory_waste_pools[plant.cell_id].mass_current += excess_mass + + # Process waste generated from predation, separate from predation b/c diff waste + self.defecate(excrement_pools, total_consumed_mass) - # Process waste generated from predation, separate from carnivory b/c diff waste - self.defecate(excrement_pool, total_consumed_mass) return total_consumed_mass def forage_cohort( self, - plant_list: Sequence[PlantResources], - animal_list: Sequence[AnimalCohort], - excrement_pool: DecayPool, - carcass_pool: CarcassPool, - herbivory_waste_pool: HerbivoryWaste, + plant_list: list[Resource], + animal_list: list[AnimalCohort], + excrement_pools: list[ExcrementPool], + carcass_pools: dict[int, list[CarcassPool]], + herbivory_waste_pools: dict[int, HerbivoryWaste], ) -> None: """This function handles selection of resources from a list for consumption. Args: - plant_list: A sequence of plant resources available for herbivory. - animal_list: A sequence of animal cohorts available for predation. - excrement_pool: A pool representing the excrement in the grid cell. - carcass_pool: A pool representing the carcasses in the grid cell. - herbivory_waste_pool: A pool representing waste caused by herbivory. + plant_list: A list of plant resources available for herbivory. + animal_list: A list of animal cohorts available for predation. + excrement_pools: The pools representing the excrement in the grid cell. + carcass_pools: The pools to which animal carcasses are delivered. + herbivory_waste_pools: A dict of pools representing waste caused by + herbivory. Return: A float value of the net change in consumer mass due to foraging. @@ -756,10 +882,14 @@ def forage_cohort( LOGGER.warning("No individuals in cohort to forage.") return + if self.mass_current == 0: + LOGGER.warning("No mass left in cohort to forage.") + return + # Herbivore diet if self.functional_group.diet == DietType.HERBIVORE and plant_list: consumed_mass = self.delta_mass_herbivory( - plant_list, excrement_pool, herbivory_waste_pool + plant_list, excrement_pools, herbivory_waste_pools ) # Directly modifies the plant mass self.eat(consumed_mass) # Accumulate net mass gain from each plant @@ -767,12 +897,12 @@ def forage_cohort( elif self.functional_group.diet == DietType.CARNIVORE and animal_list: # Calculate the mass gained from predation consumed_mass = self.delta_mass_predation( - animal_list, excrement_pool, carcass_pool + animal_list, excrement_pools, carcass_pools ) # Update the predator's mass with the total gained mass self.eat(consumed_mass) - def theta_i_j(self, animal_list: Sequence[AnimalCohort]) -> float: + def theta_i_j(self, animal_list: list[AnimalCohort]) -> float: """Cumulative density method for delta_mass_predation. The cumulative density of organisms with a mass lying within the same predator @@ -780,12 +910,12 @@ def theta_i_j(self, animal_list: Sequence[AnimalCohort]) -> float: Madingley - TODO: current format makes no sense, dig up the details in the supp - TODO: update A_cell with real reference to grid zie + TODO: current mass bin format makes no sense, dig up the details in the supp + TODO: update A_cell with real reference to grid size TODO: update name Args: - animal_list: A sequence of animal cohorts that can be consumed by the + animal_list: A list of animal cohorts that can be consumed by the predator. Returns: @@ -889,7 +1019,7 @@ def migrate_juvenile_probability(self) -> float: return min(1.0, probability_of_dispersal) def inflict_non_predation_mortality( - self, dt: float, carcass_pool: CarcassPool + self, dt: float, carcass_pools: list[CarcassPool] ) -> None: """Inflict combined background, senescence, and starvation mortalities. @@ -898,7 +1028,7 @@ def inflict_non_predation_mortality( Args: dt: The time passed in the timestep (days). - carcass_pool: The local carcass pool to which dead individuals go. + carcass_pools: The local carcass pool to which dead individuals go. """ @@ -935,4 +1065,165 @@ def inflict_non_predation_mortality( number_dead = ceil(pop_size * (1 - exp(-u_t * dt))) # Remove the dead individuals from the cohort - self.die_individual(number_dead, carcass_pool) + self.die_individual(number_dead, carcass_pools) + + def get_prey( + self, + communities: dict[int, list[AnimalCohort]], + ) -> list[AnimalCohort]: + """Collect suitable prey for a given consumer cohort. + + This method filters suitable prey from the list of animal cohorts across the + territory defined by the cohort's grid cells. + + Args: + communities: A dictionary mapping cell IDs to sets of Consumers + (animal cohorts). + consumer_cohort: The Consumer for which a prey list is being collected. + + Returns: + A sequence of Consumers that can be preyed upon. + """ + prey_list: list = [] + + # Iterate over the grid cells in the consumer cohort's territory + for cell_id in self.territory: + potential_prey_cohorts = communities[cell_id] + + # Iterate over each Consumer (potential prey) in the current community + for prey_cohort in potential_prey_cohorts: + # Skip if this cohort is not a prey of the current predator + if prey_cohort.functional_group not in self.prey_groups: + continue + + # Get the size range of the prey this predator eats + min_size, max_size = self.prey_groups[prey_cohort.functional_group.name] + + # Filter the potential prey cohorts based on their size + if ( + min_size <= prey_cohort.mass_current <= max_size + and prey_cohort.individuals != 0 + and prey_cohort is not self + ): + prey_list.append(prey_cohort) + + return prey_list + + def get_plant_resources( + self, plant_resources: dict[int, list[Resource]] + ) -> list[Resource]: + """Returns a list of plant resources in this territory. + + This method checks which grid cells are within this territory + and returns a list of the plant resources available in those grid cells. + + Args: + plant_resources: A dictionary of plants where keys are grid cell IDs. + + Returns: + A list of Resource objects in this territory. + """ + plant_resources_in_territory: list = [] + + # 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: + plant_resources_in_territory.extend(plant_resources[cell_id]) + + return plant_resources_in_territory + + def get_excrement_pools( + self, excrement_pools: dict[int, list[ExcrementPool]] + ) -> list[ExcrementPool]: + """Returns a list of excrement pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the excrement pools available in those grid cells. + + Args: + excrement_pools: A dictionary of excrement pools where keys are grid + cell IDs. + + Returns: + A list of ExcrementPool objects in this territory. + """ + excrement_pools_in_territory: list[ExcrementPool] = [] + + # 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. + + 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 get_carcass_pools( + self, carcass_pools: dict[int, list[CarcassPool]] + ) -> list[CarcassPool]: + """Returns a list of carcass pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the carcass pools available in those grid cells. + + Args: + carcass_pools: A dictionary of carcass pools where keys are grid + cell IDs. + + Returns: + A list of CarcassPool objects in this 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 + + def find_intersecting_carcass_pools( + self, + prey_territory: list[int], + carcass_pools: dict[int, list[CarcassPool]], + ) -> list[CarcassPool]: + """Find the carcass pools of the intersection of two territories. + + Args: + prey_territory: Another AnimalTerritory to find the intersection with. + carcass_pools: A dictionary mapping cell IDs to CarcassPool objects. + + Returns: + A list of CarcassPools in the intersecting grid cells. + """ + intersecting_keys = set(self.territory) & set(prey_territory) + intersecting_carcass_pools: list[CarcassPool] = [] + for cell_id in intersecting_keys: + intersecting_carcass_pools.extend(carcass_pools[cell_id]) + return intersecting_carcass_pools diff --git a/virtual_ecosystem/models/animal/animal_communities.py b/virtual_ecosystem/models/animal/animal_communities.py deleted file mode 100644 index e5377c434..000000000 --- a/virtual_ecosystem/models/animal/animal_communities.py +++ /dev/null @@ -1,453 +0,0 @@ -"""The ''animal'' module provides animal module functionality. - -Notes: -- assume each grid = 1 km2 -- assume each tick = 1 day (28800s) -- damuth ~ 4.23*mass**(-3/4) indiv / km2 -""" - -from __future__ import annotations - -import random -from collections.abc import Callable, Iterable -from itertools import chain -from math import ceil - -from numpy import timedelta64 - -from virtual_ecosystem.core.data import Data -from virtual_ecosystem.core.logger import LOGGER -from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_traits import DevelopmentType -from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import ( - CarcassPool, - ExcrementPool, - HerbivoryWaste, -) -from virtual_ecosystem.models.animal.functional_group import ( - FunctionalGroup, - get_functional_group_by_name, -) -from virtual_ecosystem.models.animal.plant_resources import PlantResources -from virtual_ecosystem.models.animal.scaling_functions import damuths_law - - -class AnimalCommunity: - """This is a class for the animal community of a grid cell. - - This class manages the animal cohorts present in a grid cell and provides methods - that need to loop over all cohorts, move cohorts to new grids, or manage an - interaction between two cohorts. - - Args: - functional_groups: A list of FunctionalGroup objects - data: The core data object - community_key: The integer key of the cell id for this community - neighbouring_keys: A list of cell id keys for neighbouring communities - get_destination: A function to return a destination AnimalCommunity for - migration. - """ - - def __init__( - self, - functional_groups: list[FunctionalGroup], - data: Data, - community_key: int, - neighbouring_keys: list[int], - get_destination: Callable[[int], AnimalCommunity], - constants: AnimalConsts = AnimalConsts(), - ) -> None: - # The constructor of the AnimalCommunity class. - self.data = data - """A reference to the core data object.""" - self.functional_groups = tuple(functional_groups) - """A list of all FunctionalGroup types in the model.""" - self.community_key = community_key - """Integer designation of the community in the model grid.""" - self.neighbouring_keys = neighbouring_keys - """List of integer keys of neighbouring communities.""" - self.get_destination = get_destination - """Callable get_destination from AnimalModel.""" - self.constants = constants - """Animal constants.""" - - self.animal_cohorts: dict[str, list[AnimalCohort]] = { - k.name: [] for k in self.functional_groups - } - """A dictionary of lists of animal cohort keyed by functional group.""" - # 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 - self.carcass_pool: CarcassPool = CarcassPool( - scavengeable_carbon=1e-3, - scavengeable_nitrogen=1e-4, - scavengeable_phosphorus=1e-6, - decomposed_carbon=0.0, - decomposed_nitrogen=0.0, - decomposed_phosphorus=0.0, - ) - """A pool for animal carcasses within the community.""" - self.excrement_pool: ExcrementPool = ExcrementPool( - scavengeable_carbon=1e-3, - scavengeable_nitrogen=1e-4, - scavengeable_phosphorus=1e-6, - decomposed_carbon=0.0, - decomposed_nitrogen=0.0, - decomposed_phosphorus=0.0, - ) - """A pool for excrement within the community.""" - self.leaf_waste_pool: HerbivoryWaste = HerbivoryWaste(plant_matter_type="leaf") - """A pool for leaves removed by herbivory but not actually consumed.""" - - @property - def all_animal_cohorts(self) -> Iterable[AnimalCohort]: - """Get an iterable of all animal cohorts in the community. - - This property provides access to all the animal cohorts contained - within this community class. - - Returns: - Iterable[AnimalCohort]: An iterable of AnimalCohort objects. - """ - return chain.from_iterable(self.animal_cohorts.values()) - - def populate_community(self) -> None: - """This function creates an instance of each functional group. - - Currently, this is the simplest implementation of populating the animal model. - In each AnimalCommunity one AnimalCohort of each FunctionalGroup type is - generated. So the more functional groups that are made, the denser the animal - community will be. This function will need to be reworked dramatically later on. - - Currently, the number of individuals in a cohort is handled using Damuth's Law, - which only holds for mammals. - - TODO: Move populate_community to following Madingley instead of damuth - - """ - for functional_group in self.functional_groups: - individuals = damuths_law( - functional_group.adult_mass, functional_group.damuths_law_terms - ) - - cohort = AnimalCohort( - functional_group, - functional_group.adult_mass, - 0.0, - individuals, - self.constants, - ) - self.animal_cohorts[functional_group.name].append(cohort) - - def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None: - """Function to move an AnimalCohort between AnimalCommunity objects. - - This function should take a cohort and a destination community and then pop the - cohort from this community to the destination. - - TODO: travel distance should be a function of body-size or locomotion once - multi-grid occupancy is integrated. - - Args: - migrant: The AnimalCohort moving between AnimalCommunities. - destination: The AnimalCommunity the cohort is moving to. - - """ - - self.animal_cohorts[migrant.name].remove(migrant) - destination.animal_cohorts[migrant.name].append(migrant) - - def migrate_community(self) -> None: - """This handles migrating all cohorts in a community. - - This migration method initiates migration for two reasons: - 1) The cohort is starving and needs to move for a chance at resource access - 2) An initial migration event immediately after birth. - - """ - for cohort in self.all_animal_cohorts: - migrate = cohort.is_below_mass_threshold( - self.constants.dispersal_mass_threshold - ) or ( - cohort.age == 0.0 - and random.random() <= cohort.migrate_juvenile_probability() - ) - - if not migrate: - return - - destination_key = random.choice(self.neighbouring_keys) - destination = self.get_destination(destination_key) - self.migrate(cohort, destination) - - def remove_dead_cohort(self, cohort: AnimalCohort) -> None: - """Remove a dead cohort from a community. - - Args: - cohort: The AnimalCohort instance that has lost all individuals. - - """ - - if not cohort.is_alive: - self.animal_cohorts[cohort.name].remove(cohort) - elif cohort.is_alive: - LOGGER.exception("An animal cohort which is alive cannot be removed.") - - def remove_dead_cohort_community(self) -> None: - """This handles remove_dead_cohort for all cohorts in a community.""" - for cohort in chain.from_iterable(self.animal_cohorts.values()): - if cohort.individuals <= 0: - cohort.is_alive = False - self.remove_dead_cohort(cohort) - - def birth(self, parent_cohort: AnimalCohort) -> None: - """Produce a new AnimalCohort through reproduction. - - A cohort can only reproduce if it has an excess of reproductive mass above a - certain threshold. The offspring will be an identical cohort of adults - with age 0 and mass=birth_mass. - - The science here follows Madingley. - - TODO: Check whether Madingley discards excess reproductive mass. - TODO: Rework birth mass for indirect developers. - - Args: - parent_cohort: The AnimalCohort instance which is producing a new cohort. - """ - # semelparous organisms use a portion of their non-reproductive mass to make - # offspring and then they die - non_reproductive_mass_loss = 0.0 - if parent_cohort.functional_group.reproductive_type == "semelparous": - non_reproductive_mass_loss = ( - parent_cohort.mass_current - * parent_cohort.constants.semelparity_mass_loss - ) - parent_cohort.mass_current -= non_reproductive_mass_loss - # kill the semelparous parent cohort - parent_cohort.is_alive = False - - number_offspring = ( - int( - (parent_cohort.reproductive_mass + non_reproductive_mass_loss) - / parent_cohort.functional_group.birth_mass - ) - * parent_cohort.individuals - ) - - # reduce reproductive mass by amount used to generate offspring - parent_cohort.reproductive_mass = 0.0 - - offspring_cohort = AnimalCohort( - get_functional_group_by_name( - self.functional_groups, - parent_cohort.functional_group.offspring_functional_group, - ), - parent_cohort.functional_group.birth_mass, - 0.0, - number_offspring, - self.constants, - ) - - # add a new cohort of the parental type to the community - self.animal_cohorts[parent_cohort.name].append(offspring_cohort) - - if parent_cohort.functional_group.reproductive_type == "semelparous": - self.remove_dead_cohort(parent_cohort) - - def birth_community(self) -> None: - """This handles birth for all cohorts in a community.""" - - # reproduction occurs for cohorts with sufficient reproductive mass - for cohort in self.all_animal_cohorts: - if ( - not cohort.is_below_mass_threshold(self.constants.birth_mass_threshold) - and cohort.functional_group.reproductive_type != "nonreproductive" - ): - self.birth(cohort) - - def forage_community(self) -> None: - """This function organizes the foraging of animal cohorts. - - It loops over every animal cohort in the community and calls the - forage_cohort function with a list of suitable trophic resources. This action - initiates foraging for those resources, with mass transfer details handled - internally by forage_cohort and its helper functions. Future expansions may - include functions for handling scavenging and soil consumption behaviors. - - Cohorts with no remaining individuals post-foraging are marked for death. - """ - # Generate the plant resources for foraging. - plant_community: PlantResources = PlantResources( - data=self.data, - cell_id=self.community_key, - constants=self.constants, - ) - - plant_list = [plant_community] - - for consumer_cohort in self.all_animal_cohorts: - # Prepare the prey list for the consumer cohort - prey_list = self.collect_prey(consumer_cohort) - - # Initiate foraging for the consumer cohort with the prepared resources - consumer_cohort.forage_cohort( - plant_list=plant_list, - animal_list=prey_list, - excrement_pool=self.excrement_pool, - carcass_pool=self.carcass_pool, - herbivory_waste_pool=self.leaf_waste_pool, - ) - - # Check if the cohort has been depleted to zero individuals post-foraging - if consumer_cohort.individuals == 0: - self.remove_dead_cohort(consumer_cohort) - - def collect_prey(self, consumer_cohort: AnimalCohort) -> list[AnimalCohort]: - """Collect suitable prey for a given consumer cohort. - - This is a helper function for forage_community to isolate the prey selection - functionality. - - Args: - consumer_cohort: The AnimalCohort for which a prey list is being collected - - Returns: - A list of AnimalCohorts that can be preyed upon. - - """ - prey: list = [] - for ( - prey_functional_group, - potential_prey_cohorts, - ) in self.animal_cohorts.items(): - # Skip if this functional group is not a prey of current predator - if prey_functional_group not in consumer_cohort.prey_groups: - continue - - # Get the size range of the prey this predator eats - min_size, max_size = consumer_cohort.prey_groups[prey_functional_group] - - # Filter the potential prey cohorts based on their size - for cohort in potential_prey_cohorts: - if ( - min_size <= cohort.mass_current <= max_size - and cohort.individuals != 0 - and cohort is not consumer_cohort - ): - prey.append(cohort) - - return prey - - def metabolize_community(self, temperature: float, dt: timedelta64) -> None: - """This handles metabolize for all cohorts in a community. - - This method generates a total amount of metabolic waste per cohort and passes - that waste to handler methods for distinguishing between nitrogenous and - carbonaceous wastes as they need depositing in different pools. This will not - be fully implemented until the stoichiometric rework. - - Respiration wastes are totaled because they are CO2 and not tracked spatially. - Excretion wastes are handled cohort by cohort because they will need to be - spatially explicit with multi-grid occupancy. - - TODO: Rework with stoichiometry - - Args: - temperature: Current air temperature (K). - dt: Number of days over which the metabolic costs should be calculated. - - """ - total_carbonaceous_waste = 0.0 - - for cohort in self.all_animal_cohorts: - metabolic_waste_mass = cohort.metabolize(temperature, dt) - total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) - cohort.excrete( - metabolic_waste_mass, - self.excrement_pool, - ) - - # Update the total_animal_respiration for this community using community_key. - - self.data["total_animal_respiration"].loc[{"cell_id": self.community_key}] += ( - total_carbonaceous_waste - ) - - def increase_age_community(self, dt: timedelta64) -> None: - """This handles age for all cohorts in a community. - - Args: - dt: Number of days over which the metabolic costs should be calculated. - - """ - for cohort in self.all_animal_cohorts: - cohort.increase_age(dt) - - def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: - """This handles natural mortality for all cohorts in a community. - - This includes background mortality, starvation, and, for mature cohorts, - senescence. - - Args: - dt: Number of days over which the metabolic costs should be calculated. - - """ - number_of_days = float(dt / timedelta64(1, "D")) - for cohort in self.all_animal_cohorts: - cohort.inflict_non_predation_mortality(number_of_days, self.carcass_pool) - if cohort.individuals <= 0: - cohort.is_alive = False - self.remove_dead_cohort(cohort) - - def metamorphose(self, larval_cohort: AnimalCohort) -> None: - """This transforms a larval status cohort into an adult status cohort. - - This method takes an indirect developing cohort in its larval form, - inflicts a mortality rate, and creates an adult cohort of the correct type. - - TODO: Build in a relationship between larval_cohort mass and adult cohort mass. - TODO: Is adult_mass the correct mass threshold? - TODO: If the time step drops below a month, this needs an intermediary stage. - - Args: - larval_cohort: The cohort in its larval stage to be transformed. - """ - - # inflict a mortality - number_dead = ceil( - larval_cohort.individuals * larval_cohort.constants.metamorph_mortality - ) - larval_cohort.die_individual(number_dead, self.carcass_pool) - # collect the adult functional group - adult_functional_group = get_functional_group_by_name( - self.functional_groups, - larval_cohort.functional_group.offspring_functional_group, - ) - # create the adult cohort - adult_cohort = AnimalCohort( - adult_functional_group, - adult_functional_group.birth_mass, - 0.0, - larval_cohort.individuals, - self.constants, - ) - - # add a new cohort of the parental type to the community - self.animal_cohorts[adult_cohort.name].append(adult_cohort) - - # remove the larval cohort - larval_cohort.is_alive = False - self.remove_dead_cohort(larval_cohort) - - def metamorphose_community(self) -> None: - """Handle metamorphosis for all applicable cohorts in the community.""" - - for cohort in self.all_animal_cohorts: - if ( - cohort.functional_group.development_type == DevelopmentType.INDIRECT - and (cohort.mass_current >= cohort.functional_group.adult_mass) - ): - self.metamorphose(cohort) diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 8461f98ff..b95d42429 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -18,8 +18,10 @@ from __future__ import annotations -from math import sqrt +from math import ceil, sqrt +from random import choice, random from typing import Any +from uuid import UUID from numpy import array, timedelta64, zeros from xarray import DataArray @@ -31,10 +33,21 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.core.logger import LOGGER from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort -from virtual_ecosystem.models.animal.animal_communities import AnimalCommunity +from virtual_ecosystem.models.animal.animal_traits import DevelopmentType, DietType from virtual_ecosystem.models.animal.constants import AnimalConsts -from virtual_ecosystem.models.animal.decay import LitterPool -from virtual_ecosystem.models.animal.functional_group import FunctionalGroup +from virtual_ecosystem.models.animal.decay import ( + CarcassPool, + ExcrementPool, + HerbivoryWaste, + LitterPool, +) +from virtual_ecosystem.models.animal.functional_group import ( + FunctionalGroup, + get_functional_group_by_name, +) +from virtual_ecosystem.models.animal.plant_resources import PlantResources +from virtual_ecosystem.models.animal.protocols import Resource +from virtual_ecosystem.models.animal.scaling_functions import damuths_law class AnimalModel( @@ -123,23 +136,74 @@ def __init__( self.update_interval_timedelta = timedelta64(int(days_as_float), "D") """Convert pint update_interval to timedelta64 once during initialization.""" - self._setup_grid_neighbors() + self._setup_grid_neighbours() """Determine grid square adjacency.""" + self.core_components = core_components + """The core components of the models.""" self.functional_groups = functional_groups """List of functional groups in the model.""" - self.communities: dict[int, AnimalCommunity] = {} - """Set empty dict for populating with communities.""" self.model_constants = model_constants """Animal constants.""" + self.plant_resources: dict[int, list[Resource]] = { + cell_id: [ + PlantResources( + data=self.data, cell_id=cell_id, constants=self.model_constants + ) + ] + for cell_id in self.data.grid.cell_id + } + """The plant resource pools in the model with associated grid cell ids.""" + # 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 + self.excrement_pools: dict[int, list[ExcrementPool]] = { + cell_id: [ + ExcrementPool( + scavengeable_carbon=1e-3, + scavengeable_nitrogen=1e-4, + scavengeable_phosphorus=1e-6, + decomposed_carbon=0.0, + decomposed_nitrogen=0.0, + decomposed_phosphorus=0.0, + ) + ] + for cell_id in self.data.grid.cell_id + } + """The excrement pools in the model with associated grid cell ids.""" + self.carcass_pools: dict[int, list[CarcassPool]] = { + cell_id: [ + CarcassPool( + scavengeable_carbon=1e-3, + scavengeable_nitrogen=1e-4, + scavengeable_phosphorus=1e-6, + decomposed_carbon=0.0, + decomposed_nitrogen=0.0, + decomposed_phosphorus=0.0, + ) + ] + for cell_id in self.data.grid.cell_id + } + """The carcass pools in the model with associated grid cell ids.""" + self.leaf_waste_pools: dict[int, HerbivoryWaste] = { + cell_id: HerbivoryWaste(plant_matter_type="leaf") + for cell_id in self.data.grid.cell_id + } + """A pool for leaves removed by herbivory but not actually consumed.""" + self.cohorts: dict[UUID, AnimalCohort] = {} + """A dictionary of all animal cohorts and their unique ids.""" + self.communities: dict[int, list[AnimalCohort]] = { + cell_id: list() for cell_id in self.data.grid.cell_id + } + """The animal cohorts organized by cell_id.""" self._initialize_communities(functional_groups) """Create the dictionary of animal communities and populate each community with animal cohorts.""" self.setup() """Initialize the data variables used by the animal model.""" - def _setup_grid_neighbors(self) -> None: - """Set up grid neighbors for the model. + def _setup_grid_neighbours(self) -> None: + """Set up grid neighbours for the model. Currently, this is redundant with the set_neighbours method of grid. This will become a more complex animal specific implementation to manage @@ -148,45 +212,37 @@ def _setup_grid_neighbors(self) -> None: """ self.data.grid.set_neighbours(distance=sqrt(self.data.grid.cell_area)) - def get_community_by_key(self, key: int) -> AnimalCommunity: - """Function to return the AnimalCommunity present in a given grid square. - - This function exists principally to provide a callable for AnimalCommunity. - - Args: - key: The specific grid square integer key associated with the community. - - Returns: - The AnimalCommunity object in that grid square. - - """ - return self.communities[key] - def _initialize_communities(self, functional_groups: list[FunctionalGroup]) -> None: - """Initialize the animal communities. + """Initialize the animal communities by creating and populating animal cohorts. Args: functional_groups: The list of functional groups that will populate the model. """ + # Initialize communities dictionary with cell IDs as keys and empty lists for + # cohorts + self.communities = {cell_id: list() for cell_id in self.data.grid.cell_id} + + # Iterate over each cell and functional group to create and populate cohorts + for cell_id in self.data.grid.cell_id: + for functional_group in functional_groups: + # Calculate the number of individuals using Damuth's Law + individuals = damuths_law( + functional_group.adult_mass, functional_group.damuths_law_terms + ) - # Generate a dictionary of AnimalCommunity objects, one per grid cell. - self.communities = { - k: AnimalCommunity( - functional_groups=functional_groups, - data=self.data, - community_key=k, - neighbouring_keys=list(self.data.grid.neighbours[k]), - get_destination=self.get_community_by_key, - constants=self.model_constants, - ) - for k in self.data.grid.cell_id - } - - # Create animal cohorts in each grid square's animal community according to the - # populate_community method. - for community in self.communities.values(): - community.populate_community() + # Create a cohort of the functional group + cohort = AnimalCohort( + functional_group=functional_group, + mass=functional_group.adult_mass, + age=0.0, + individuals=individuals, + centroid_key=cell_id, + grid=self.data.grid, + constants=self.model_constants, + ) + self.cohorts[cohort.id] = cohort + self.communities[cell_id].append(cohort) @classmethod def from_config( @@ -234,13 +290,13 @@ def setup(self) -> None: # animal respiration data variable # the array should have one value for each animal community - n_communities = len(self.data.grid.cell_id) + n_grid_cells = len(self.data.grid.cell_id) # Initialize total_animal_respiration as a DataArray with a single dimension: # cell_id total_animal_respiration = DataArray( zeros( - n_communities + n_grid_cells ), # Filled with zeros to start with no carbon production. dims=["cell_id"], coords={"cell_id": self.data.grid.cell_id}, @@ -254,7 +310,7 @@ def setup(self) -> None: functional_group_names = [fg.name for fg in self.functional_groups] # Assuming self.communities is a dict with community_id as keys - community_ids = list(self.communities.keys()) + community_ids = self.data.grid.cell_id # Create a multi-dimensional array for population densities population_densities = DataArray( @@ -283,29 +339,29 @@ def update(self, time_index: int, **kwargs: Any) -> None: events would be simultaneous. The ordering within the method is less a question of the science and more a question of computational logic and stability. + TODO: update so that it just cycles through the community methods, each of those + will cycle through all cohorts in the model + Args: time_index: The index representing the current time step in the data object. **kwargs: Further arguments to the update method. """ + # TODO: merge problems as community looping is not internal to comm methods # TODO: These pools are populated but nothing actually gets done with them at # the moment, this will have to change when scavenging gets introduced litter_pools = self.populate_litter_pools() - for community in self.communities.values(): - community.forage_community() - community.migrate_community() - community.birth_community() - community.metamorphose_community() - community.metabolize_community( - float(self.data["air_temperature"][0][community.community_key].values), - self.update_interval_timedelta, - ) - community.inflict_non_predation_mortality_community( - self.update_interval_timedelta - ) - community.remove_dead_cohort_community() - community.increase_age_community(self.update_interval_timedelta) + self.forage_community() + self.migrate_community() + self.birth_community() + self.metamorphose_community() + self.metabolize_community( + self.update_interval_timedelta, + ) + self.inflict_non_predation_mortality_community(self.update_interval_timedelta) + self.remove_dead_cohort_community() + self.increase_age_community(self.update_interval_timedelta) # Now that communities have been updated information required to update the # soil and litter models can be extracted @@ -325,7 +381,20 @@ def cleanup(self) -> None: """Placeholder function for animal model cleanup.""" def populate_litter_pools(self) -> dict[str, LitterPool]: - """Populate the litter pools that animals can consume from.""" + """Populate the litter pools that animals can consume from. + + Returns: + dict[str, LitterPool]: A dictionary where keys represent the pool types and + values are the corresponding `LitterPool` objects. The following pools are + included: + + - "above_metabolic": Litter pool for above-ground metabolic organic matter + - "above_structural": Litter pool for above-ground structural organic matter + - "woody": Litter pool for woody biomass + - "below_metabolic": Litter pool for below-ground metabolic organic matter + - "below_structural": Litter pool for below-ground structural organic matter + + """ return { "above_metabolic": LitterPool( @@ -360,6 +429,8 @@ def calculate_total_litter_consumption( ) -> dict[str, DataArray]: """Calculate total animal consumption of each litter pool. + TODO: rework for merge + Args: litter_pools: The full set of animal accessible litter pools. @@ -386,6 +457,7 @@ def calculate_litter_additions_from_herbivory(self) -> dict[str, DataArray]: TODO - At present the only type of herbivory this works for is leaf herbivory, that should be changed once herbivory as a whole is fleshed out. + TODO: rework for merge Returns: A dictionary containing details of the leaf litter addition due to herbivory @@ -396,26 +468,26 @@ def calculate_litter_additions_from_herbivory(self) -> dict[str, DataArray]: # Find the size of the leaf waste pool (in carbon terms) leaf_addition = [ - community.leaf_waste_pool.mass_current / self.data.grid.cell_area - for community in self.communities.values() + self.leaf_waste_pools[cell_id].mass_current / self.data.grid.cell_area + for cell_id in self.data.grid.cell_id ] # Find the chemistry of the pools as well leaf_c_n = [ - community.leaf_waste_pool.c_n_ratio - for community in self.communities.values() + self.leaf_waste_pools[cell_id].c_n_ratio + for cell_id in self.data.grid.cell_id ] leaf_c_p = [ - community.leaf_waste_pool.c_p_ratio - for community in self.communities.values() + self.leaf_waste_pools[cell_id].c_p_ratio + for cell_id in self.data.grid.cell_id ] leaf_lignin = [ - community.leaf_waste_pool.lignin_proportion - for community in self.communities.values() + self.leaf_waste_pools[cell_id].lignin_proportion + for cell_id in self.data.grid.cell_id ] # Reset all of the herbivory waste pools to zero - for community in self.communities.values(): - community.leaf_waste_pool.mass_current = 0.0 + for waste in self.leaf_waste_pools.values(): + waste.mass_current = 0.0 return { "herbivory_waste_leaf_carbon": DataArray( @@ -431,39 +503,47 @@ def calculate_litter_additions_from_herbivory(self) -> dict[str, DataArray]: } def calculate_soil_additions(self) -> dict[str, DataArray]: - """Calculate the how much animal matter should be transferred to the soil.""" + """Calculate how much animal matter should be transferred to the soil.""" nutrients = ["carbon", "nitrogen", "phosphorus"] - # Find the size of all decomposed excrement and carcass pools + + # Find the size of all decomposed excrement and carcass pools, by cell_id decomposed_excrement = { nutrient: [ - community.excrement_pool.decomposed_nutrient_per_area( + pool.decomposed_nutrient_per_area( nutrient=nutrient, grid_cell_area=self.data.grid.cell_area ) - for community in self.communities.values() + for cell_id, pools in self.excrement_pools.items() + for pool in pools ] for nutrient in nutrients } + decomposed_carcasses = { nutrient: [ - community.carcass_pool.decomposed_nutrient_per_area( + pool.decomposed_nutrient_per_area( nutrient=nutrient, grid_cell_area=self.data.grid.cell_area ) - for community in self.communities.values() + for cell_id, pools in self.carcass_pools.items() + for pool in pools ] for nutrient in nutrients } - # All excrement and carcasses in their respective decomposed subpools are moved - # to the litter model, so stored carbon of each subpool is reset to zero - for community in self.communities.values(): - community.excrement_pool.decomposed_carbon = 0.0 - community.excrement_pool.decomposed_nitrogen = 0.0 - community.excrement_pool.decomposed_phosphorus = 0.0 - community.carcass_pool.decomposed_carbon = 0.0 - community.carcass_pool.decomposed_nitrogen = 0.0 - community.carcass_pool.decomposed_phosphorus = 0.0 + # Reset all decomposed excrement pools to zero + for excrement_pools in self.excrement_pools.values(): + for excrement_pool in excrement_pools: + excrement_pool.decomposed_carbon = 0.0 + excrement_pool.decomposed_nitrogen = 0.0 + excrement_pool.decomposed_phosphorus = 0.0 + + for carcass_pools in self.carcass_pools.values(): + for carcass_pool in carcass_pools: + carcass_pool.decomposed_carbon = 0.0 + carcass_pool.decomposed_nitrogen = 0.0 + carcass_pool.decomposed_phosphorus = 0.0 + # Create the output DataArray for each nutrient return { "decomposed_excrement_carbon": DataArray( array(decomposed_excrement["carbon"]) @@ -501,16 +581,21 @@ def update_population_densities(self) -> None: """Updates the densities for each functional group in each community.""" for community_id, community in self.communities.items(): - for fg_name, cohorts in community.animal_cohorts.items(): - # Initialize the population density of the functional group - fg_density = 0.0 - for cohort in cohorts: - # Calculate the population density for the cohort - fg_density += self.calculate_density_for_cohort(cohort) - - # Update the corresponding entry in the data variable - # This update should happen once per functional group after summing - # all cohort densities + # Create a dictionary to accumulate densities by functional group + fg_density_dict = {} + + for cohort in community: + fg_name = cohort.functional_group.name + fg_density = self.calculate_density_for_cohort(cohort) + + # Sum the density for the functional group + if fg_name not in fg_density_dict: + fg_density_dict[fg_name] = 0.0 + fg_density_dict[fg_name] += fg_density + + # Update the corresponding entries in the data variable for each + # functional group + for fg_name, fg_density in fg_density_dict.items(): self.data["population_densities"].loc[ {"community_id": community_id, "functional_group_id": fg_name} ] = fg_density @@ -534,3 +619,391 @@ def calculate_density_for_cohort(self, cohort: AnimalCohort) -> float: population_density = cohort.individuals / community_area return population_density + + def abandon_communities(self, cohort: AnimalCohort) -> None: + """Removes the cohort from the occupancy of every community. + + This method is for use in death or re-initializing territories. + + Args: + cohort: The cohort to be removed from the occupancy lists. + """ + for cell_id in cohort.territory: + self.communities[cell_id] = [ + c for c in self.communities[cell_id] if c.id != cohort.id + ] + + def update_community_occupancy( + self, cohort: AnimalCohort, centroid_key: int + ) -> None: + """This updates the community lists for animal cohort occupancy. + + Args: + cohort: The animal cohort being updates. + centroid_key: The grid cell key of the anchoring grid cell. + """ + + territory_cells = cohort.get_territory_cells(centroid_key) + cohort.update_territory(territory_cells) + + for cell_id in territory_cells: + self.communities[cell_id].append(cohort) + + def migrate(self, migrant: AnimalCohort, destination_centroid: int) -> None: + """Function to move an AnimalCohort between grid cells. + + This function takes a cohort and a destination grid cell, changes the + centroid of the cohort's territory to be the new cell, and then + reinitializes the territory around the new centroid. + + TODO: travel distance should be a function of body-size or locomotion once + multi-grid occupancy is integrated. + + Args: + migrant: The AnimalCohort moving between AnimalCommunities. + destination_centroid: The grid cell the cohort is moving to. + + """ + + # Remove the cohort from its current community + current_centroid = migrant.centroid_key + self.communities[current_centroid].remove(migrant) + + # Update the cohort's cell ID to the destination cell ID + migrant.centroid_key = destination_centroid + + # Add the cohort to the destination community + self.communities[destination_centroid].append(migrant) + + # Regenerate a territory for the cohort at the destination community + self.abandon_communities(migrant) + self.update_community_occupancy(migrant, destination_centroid) + + def migrate_community(self) -> None: + """This handles migrating all cohorts with a centroid in the community. + + This migration method initiates migration for two reasons: + 1) The cohort is starving and needs to move for a chance at resource access + 2) An initial migration event immediately after birth. + + TODO: MGO - migrate distance mod for larger territories? + + + """ + for cohort in self.cohorts.values(): + is_starving = cohort.is_below_mass_threshold( + self.model_constants.dispersal_mass_threshold + ) + is_juvenile_and_migrate = ( + cohort.age == 0.0 and random() <= cohort.migrate_juvenile_probability() + ) + migrate = is_starving or is_juvenile_and_migrate + + if not migrate: + continue + + # Get the list of neighbors for the current cohort's cell + neighbour_keys = self.data.grid.neighbours[cohort.centroid_key] + + destination_key = choice(neighbour_keys) + self.migrate(cohort, destination_key) + + def remove_dead_cohort(self, cohort: AnimalCohort) -> None: + """Removes an AnimalCohort from the model's cohorts and relevant communities. + + This method removes the cohort from every community listed in its territory's + grid cell keys, and then removes it from the model's main cohort dictionary. + + Args: + cohort: The AnimalCohort to be removed. + + Raises: + KeyError: If the cohort ID does not exist in the model's cohorts. + """ + # Check if the cohort exists in self.cohorts + if cohort.id in self.cohorts: + # Iterate over all grid cell keys in the cohort's territory + for cell_id in cohort.territory: + if cell_id in self.communities and cohort in self.communities[cell_id]: + self.communities[cell_id].remove(cohort) + + # Remove the cohort from the model's cohorts dictionary + del self.cohorts[cohort.id] + else: + raise KeyError(f"Cohort with ID {cohort.id} does not exist.") + + def remove_dead_cohort_community(self) -> None: + """This handles remove_dead_cohort for all cohorts in a community.""" + # Collect cohorts to remove (to avoid modifying the dictionary during iteration) + cohorts_to_remove = [ + cohort for cohort in self.cohorts.values() if cohort.individuals <= 0 + ] + + # Remove each cohort + for cohort in cohorts_to_remove: + cohort.is_alive = False + self.remove_dead_cohort(cohort) + + def birth(self, parent_cohort: AnimalCohort) -> None: + """Produce a new AnimalCohort through reproduction. + + A cohort can only reproduce if it has an excess of reproductive mass above a + certain threshold. The offspring will be an identical cohort of adults + with age 0 and mass=birth_mass. A new territory, likely smaller b/c allometry, + is generated for the newborn cohort. + + The science here follows Madingley. + + TODO: Check whether Madingley discards excess reproductive mass. + TODO: Rework birth mass for indirect developers. + + Args: + parent_cohort: The AnimalCohort instance which is producing a new cohort. + """ + # semelparous organisms use a portion of their non-reproductive mass to make + # offspring and then they die + non_reproductive_mass_loss = 0.0 + if parent_cohort.functional_group.reproductive_type == "semelparous": + non_reproductive_mass_loss = ( + parent_cohort.mass_current + * parent_cohort.constants.semelparity_mass_loss + ) + parent_cohort.mass_current -= non_reproductive_mass_loss + # kill the semelparous parent cohort + parent_cohort.is_alive = False + + number_offspring = ( + int( + (parent_cohort.reproductive_mass + non_reproductive_mass_loss) + / parent_cohort.functional_group.birth_mass + ) + * parent_cohort.individuals + ) + + # reduce reproductive mass by amount used to generate offspring + parent_cohort.reproductive_mass = 0.0 + + if number_offspring <= 0: + print("No offspring created, exiting birth method.") + return + + offspring_functional_group = get_functional_group_by_name( + self.functional_groups, + parent_cohort.functional_group.offspring_functional_group, + ) + + offspring_cohort = AnimalCohort( + offspring_functional_group, + parent_cohort.functional_group.birth_mass, + 0.0, + number_offspring, + parent_cohort.centroid_key, + parent_cohort.grid, + parent_cohort.constants, + ) + + # add a new cohort of the parental type to the community + self.cohorts[offspring_cohort.id] = offspring_cohort + + # Debug: Print cohorts after adding offspring + print(f"Total cohorts after adding offspring: {len(self.cohorts)}") + + # add the new cohort to the community lists it occupies + self.update_community_occupancy(offspring_cohort, offspring_cohort.centroid_key) + + if parent_cohort.functional_group.reproductive_type == "semelparous": + self.remove_dead_cohort(parent_cohort) + + def birth_community(self) -> None: + """This handles birth for all cohorts in a community.""" + + # reproduction occurs for cohorts with sufficient reproductive mass + for cohort in self.cohorts.values(): + if ( + not cohort.is_below_mass_threshold( + self.model_constants.birth_mass_threshold + ) + and cohort.functional_group.reproductive_type != "nonreproductive" + ): + self.birth(cohort) + + def forage_community(self) -> None: + """This function organizes the foraging of animal cohorts. + + Herbivores will only forage plant resources, while carnivores will forage for + prey (other animal cohorts). + + It loops over every animal cohort in the community and calls the + forage_cohort function with a list of suitable trophic resources. This action + initiates foraging for those resources, with mass transfer details handled + internally by forage_cohort and its helper functions. Future expansions may + include functions for handling scavenging and soil consumption behaviors. + + Cohorts with no remaining individuals post-foraging are marked for death. + """ + + for consumer_cohort in self.cohorts.values(): + # Check that the cohort has a valid territory defined + if consumer_cohort.territory is None: + raise ValueError("The cohort's territory hasn't been defined.") + + # Initialize empty resource lists + plant_list = [] + prey_list = [] + excrement_list = consumer_cohort.get_excrement_pools(self.excrement_pools) + """plant_waste_list = consumer_cohort.get_plant_waste_pools( + self.leaf_waste_pools + )""" + + # Check the diet of the cohort and get appropriate resources + if consumer_cohort.functional_group.diet == DietType.HERBIVORE: + plant_list = consumer_cohort.get_plant_resources(self.plant_resources) + + elif consumer_cohort.functional_group.diet == DietType.CARNIVORE: + prey_list = consumer_cohort.get_prey(self.communities) + + # Initiate foraging for the consumer cohort with the available resources + consumer_cohort.forage_cohort( + plant_list=plant_list, + animal_list=prey_list, + excrement_pools=excrement_list, + carcass_pools=self.carcass_pools, # the full list of carcass pools + herbivory_waste_pools=self.leaf_waste_pools, # full list of leaf waste + ) + + # Temporary solution to remove dead cohorts + self.remove_dead_cohort_community() + + def metabolize_community(self, dt: timedelta64) -> None: + """This handles metabolize for all cohorts in a community. + + This method generates a total amount of metabolic waste per cohort and passes + that waste to handler methods for distinguishing between nitrogenous and + carbonaceous wastes as they need depositing in different pools. This will not + be fully implemented until the stoichiometric rework. + + Respiration wastes are totaled because they are CO2 and not tracked spatially. + Excretion wastes are handled cohort by cohort because they will need to be + spatially explicit with multi-grid occupancy. + + Args: + air_temperature_data: The full air temperature data (as a DataArray) for + all communities. + dt: Number of days over which the metabolic costs should be calculated. + + """ + for cell_id, community in self.communities.items(): + # Check for empty community and skip processing if empty + if not community: + continue + + total_carbonaceous_waste = 0.0 + + # Extract the temperature for this specific community (cell_id) + surface_temperature = self.data["air_temperature"][ + self.core_components.layer_structure.index_surface_scalar + ].to_numpy() + + grid_temperature = surface_temperature[cell_id] + + for cohort in community: + # Calculate metabolic waste based on cohort properties + metabolic_waste_mass = cohort.metabolize(grid_temperature, dt) + + # Carbonaceous waste from respiration + total_carbonaceous_waste += cohort.respire(metabolic_waste_mass) + + # Excretion of waste into the excrement pool + cohort.excrete(metabolic_waste_mass, self.excrement_pools[cell_id]) + + # Update the total_animal_respiration for the specific cell_id + self.data["total_animal_respiration"].loc[{"cell_id": cell_id}] += ( + total_carbonaceous_waste + ) + + def increase_age_community(self, dt: timedelta64) -> None: + """This handles age for all cohorts in a community. + + Args: + dt: Number of days over which the metabolic costs should be calculated. + + """ + for cohort in self.cohorts.values(): + cohort.increase_age(dt) + + def inflict_non_predation_mortality_community(self, dt: timedelta64) -> None: + """This handles natural mortality for all cohorts in a community. + + This includes background mortality, starvation, and, for mature cohorts, + senescence. + + Args: + dt: Number of days over which the metabolic costs should be calculated. + + """ + number_of_days = float(dt / timedelta64(1, "D")) + for cohort in list(self.cohorts.values()): + cohort.inflict_non_predation_mortality( + number_of_days, cohort.get_carcass_pools(self.carcass_pools) + ) + if cohort.individuals <= 0: + cohort.is_alive = False + self.remove_dead_cohort(cohort) + + def metamorphose(self, larval_cohort: AnimalCohort) -> None: + """This transforms a larval status cohort into an adult status cohort. + + This method takes an indirect developing cohort in its larval form, + inflicts a mortality rate, and creates an adult cohort of the correct type. + + TODO: Build in a relationship between larval_cohort mass and adult cohort mass. + TODO: Is adult_mass the correct mass threshold? + TODO: If the time step drops below a month, this needs an intermediary stage. + + Args: + larval_cohort: The cohort in its larval stage to be transformed. + """ + + # inflict a mortality + number_dead = ceil( + larval_cohort.individuals * larval_cohort.constants.metamorph_mortality + ) + larval_cohort.die_individual( + number_dead, larval_cohort.get_carcass_pools(self.carcass_pools) + ) + # collect the adult functional group + adult_functional_group = get_functional_group_by_name( + self.functional_groups, + larval_cohort.functional_group.offspring_functional_group, + ) + # create the adult cohort + adult_cohort = AnimalCohort( + adult_functional_group, + adult_functional_group.birth_mass, + 0.0, + larval_cohort.individuals, + larval_cohort.centroid_key, + self.grid, + self.model_constants, + ) + + # add a new cohort of the parental type to the community + self.cohorts[adult_cohort.id] = adult_cohort + + # add the new cohort to the community lists it occupies + self.update_community_occupancy(adult_cohort, adult_cohort.centroid_key) + + # remove the larval cohort + larval_cohort.is_alive = False + self.remove_dead_cohort(larval_cohort) + + def metamorphose_community(self) -> None: + """Handle metamorphosis for all applicable cohorts in the community.""" + + # Iterate over a static list of cohort values + for cohort in list(self.cohorts.values()): + if ( + cohort.functional_group.development_type == DevelopmentType.INDIRECT + and (cohort.mass_current >= cohort.functional_group.adult_mass) + ): + self.metamorphose(cohort) diff --git a/virtual_ecosystem/models/animal/plant_resources.py b/virtual_ecosystem/models/animal/plant_resources.py index 6aafeb6f1..3b7615c92 100644 --- a/virtual_ecosystem/models/animal/plant_resources.py +++ b/virtual_ecosystem/models/animal/plant_resources.py @@ -6,6 +6,8 @@ from virtual_ecosystem.core.data import Data from virtual_ecosystem.models.animal.constants import AnimalConsts + +# from virtual_ecosystem.models.animal.decay import ExcrementPool from virtual_ecosystem.models.animal.protocols import Consumer @@ -19,34 +21,26 @@ class PlantResources: At present, it only exposes a single resource - the total leaf mass of the entire plant community in a cell - but this is likely to expand to allow vertical structure of plant resources, diversification to fruit and other resources and probably plant - cohort specific herbivory. + cohort-specific herbivory. Args: data: A Data object containing information from the plants model. cell_id: The cell id for the plant community to expose. + constants: Animal-related constants, including plant energy density. """ def __init__(self, data: Data, cell_id: int, constants: AnimalConsts) -> None: # Store the data and extract the appropriate plant data self.data = data """A reference to the core data object.""" - self.mass_current: float = ( - data["layer_leaf_mass"].sel(cell_id=cell_id).sum(dim="layers").item() - ) + self.cell_id = cell_id + """The community cell containing the plant resources.""" + self.mass_current: float = 10000.0 """The mass of the plant leaf mass [kg].""" self.constants = constants - """The animals constants.""" - # Calculate energy availability - # TODO - this needs to be handed back to the plants model, which will define PFT - # specific conversions to different resources. - self.energy_density: float = self.constants.energy_density["plant"] - """The energy (J) in a kg of plant [currently set to toy value of Alfalfa].""" - self.energy_max: float = self.mass_current * self.energy_density - """The maximum amount of energy that the cohort can have [J] [Alfalfa].""" - self.stored_energy = self.energy_max - """The amount of energy in the plant cohort [J] [toy].""" - self.is_alive: bool = True - """Whether the cohort is alive [True] or dead [False].""" + """The animal constants, including energy density.""" + self.is_alive = True + """Indicating whether the plant cohort is alive [True] or dead [False].""" def get_eaten( self, consumed_mass: float, herbivore: Consumer diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index c2889624e..ddd3af1a4 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -41,9 +41,12 @@ class Resource(Protocol): """This is the protocol for defining what classes work as trophic resources.""" mass_current: float + cell_id: int def get_eaten( - self, consumed_mass: float, consumer: Consumer, pool: DecayPool - ) -> float: + self, + consumed_mass: float, + consumer: Consumer, + ) -> tuple[float, float]: """The get_eaten method defines a resource.""" ... diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index f299eb993..4a1ea1ac4 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -250,7 +250,7 @@ def k_i_k(alpha_i_k: float, phi_herb_t: float, B_k_t: float, A_cell: float) -> f alpha_i_k: Effective rate at which an individual herbivore searches its environment. phi_herb_t: Fraction of the total plant stock that is available to any one - herbivore cohort + herbivore cohort (default 0.1) B_k_t: Plant resource bool biomass. A_cell: The area of one cell [standard = 1 ha] @@ -370,7 +370,6 @@ def alpha_i_j(alpha_0_pred: float, mass: float, w_bar_i_j: float) -> float: def k_i_j(alpha_i_j: float, N_i_t: float, A_cell: float, theta_i_j: float) -> float: """Potential number of prey items eaten off j by i. - TODO: Finish docstring TODO: double check output needs to be float, might be int TODO: update name @@ -439,3 +438,96 @@ def juvenile_dispersal_speed( """ return V_disp * (current_mass / M_disp_ref) ** o_disp + + +def territory_size(mass: float) -> float: + """This function provides allometric scaling for territory size. + + TODO: Replace this toy scaling with a real allometry + TODO: decide if this allometry will be based on current mass or adult mass + + Args: + mass: The mass of the animal cohort + + Returns: + The size of the cohort's territory in hectares + """ + + if mass < 10.0: + territory = 1.0 + elif 10.0 <= mass < 25.0: + territory = 2.0 + elif 25.0 <= mass < 50.0: + territory = 5.0 + elif 50.0 <= mass < 100.0: + territory = 10.0 + elif 100.0 <= mass < 200.0: + territory = 15.0 + elif 200.0 <= mass < 500.0: + territory = 20.0 + else: + territory = 30.0 + + return territory + + +def bfs_territory( + centroid_key: int, target_cell_number: int, cell_nx: int, cell_ny: int +) -> list[int]: + """Performs breadth-first search (BFS) to generate a list of territory cells. + + BFS does some slightly weird stuff on a grid of squares but behaves properly on a + graph. As we are talking about moving to a graph anyway, I can leave it like this + and make adjustments for diagonals if we decide to stay with squares/cells. + + TODO: Revise for diagonals if we stay on grid squares/cells. + TODO: might be able to save time with an ifelse for small territories + TODO: scaling territories is a temporary home while i rework territories + + Args: + centroid_key: The community key anchoring the territory. + target_cell_number: The number of grid cells in the territory. + cell_nx: Number of cells along the x-axis. + cell_ny: Number of cells along the y-axis. + + Returns: + A list of grid cell keys representing the territory. + """ + + # Convert centroid key to row and column indices + row, col = divmod(centroid_key, cell_nx) + + # Initialize the territory cells list with the centroid key + territory_cells = [centroid_key] + + # Define the possible directions for BFS traversal: Up, Down, Left, Right + directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] + + # Set to keep track of visited cells to avoid revisiting + visited = set(territory_cells) + + # Queue for BFS, initialized with the starting position (row, col) + queue = [(row, col)] + + # Perform BFS until the queue is empty or we reach the target number of cells + while queue and len(territory_cells) < target_cell_number: + # Dequeue the next cell to process + r, c = queue.pop(0) + + # Explore all neighboring cells in the defined directions + for dr, dc in directions: + nr, nc = r + dr, c + dc + # Check if the new cell is within grid bounds + if 0 <= nr < cell_ny and 0 <= nc < cell_nx: + new_cell = nr * cell_nx + nc + # If the cell hasn't been visited, mark it as visited and add to the + # territory + if new_cell not in visited: + visited.add(new_cell) + territory_cells.append(new_cell) + queue.append((nr, nc)) + # If we have reached the target number of cells, exit the loop + if len(territory_cells) >= target_cell_number: + break + + return territory_cells From b47268182c6d32dec93b97d06ece00f5e43b4e33 Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 30 Oct 2024 16:19:54 +0000 Subject: [PATCH 56/62] Updating docs for no community. --- docs/source/_toc.yaml | 2 -- .../api/models/animal/animal_communities.md | 33 ------------------- 2 files changed, 35 deletions(-) delete mode 100644 docs/source/api/models/animal/animal_communities.md diff --git a/docs/source/_toc.yaml b/docs/source/_toc.yaml index 749691f7f..24f7205cc 100644 --- a/docs/source/_toc.yaml +++ b/docs/source/_toc.yaml @@ -147,8 +147,6 @@ subtrees: entries: - file: api/models/animal/animal_cohorts title: The animal_cohorts submodule - - file: api/models/animal/animal_communities - title: The animal_communities submodule - file: api/models/animal/animal_model title: The animal_model submodule - file: api/models/animal/animal_traits diff --git a/docs/source/api/models/animal/animal_communities.md b/docs/source/api/models/animal/animal_communities.md deleted file mode 100644 index a500d94b5..000000000 --- a/docs/source/api/models/animal/animal_communities.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -jupytext: - cell_metadata_filter: -all - formats: md:myst - main_language: python - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.16.4 -kernelspec: - display_name: Python 3 (ipykernel) - language: python - name: python3 -language_info: - codemirror_mode: - name: ipython - version: 3 - file_extension: .py - mimetype: text/x-python - name: python - nbconvert_exporter: python - pygments_lexer: ipython3 - version: 3.11.9 ---- - -# API for the {mod}`~virtual_ecosystem.models.animal.animal_communities` module - -```{eval-rst} -.. automodule:: virtual_ecosystem.models.animal.animal_communities - :autosummary: - :members: - :exclude-members: model_name From a20df22c9d2dafb49db0ac0488d145fddb90acea Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Wed, 30 Oct 2024 16:21:34 +0000 Subject: [PATCH 57/62] Updating docs for no community p2. --- virtual_ecosystem/models/animal/__init__.py | 2 -- virtual_ecosystem/models/animal/animal_cohorts.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/virtual_ecosystem/models/animal/__init__.py b/virtual_ecosystem/models/animal/__init__.py index 289c70226..f7aa1de80 100644 --- a/virtual_ecosystem/models/animal/__init__.py +++ b/virtual_ecosystem/models/animal/__init__.py @@ -7,8 +7,6 @@ AnimalModel class which consolidates the functionality of the animal module into a single class, which the high level functions of the Virtual Ecosystem can then make use of. -* The :mod:`~virtual_ecosystem.models.animal.animal_communities` provides a class for - containing and managing all of the animal cohorts within a grid square. * The :mod:`~virtual_ecosystem.models.animal.animal_cohorts` provides a class for the individual animal cohorts, their attributes, and behaviors. * The :mod:`~virtual_ecosystem.models.animal.functional_group` provides a class for diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 8d6e3195a..2ca4fbd21 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -3,7 +3,7 @@ from __future__ import annotations from math import ceil, exp, sqrt -from uuid import uuid4 +from uuid import UUID, uuid4 from numpy import timedelta64 @@ -60,7 +60,7 @@ def __init__( """The the grid structure of the simulation.""" self.constants = constants """Animal constants.""" - self.id = uuid4() + self.id: UUID = uuid4() """A unique identifier for the cohort.""" self.damuth_density: int = sf.damuths_law( self.functional_group.adult_mass, self.functional_group.damuths_law_terms From 4b337ae3e1e5252586f6fdf9ff24aa55d846e09a Mon Sep 17 00:00:00 2001 From: David Orme Date: Wed, 30 Oct 2024 16:48:31 +0000 Subject: [PATCH 58/62] Docstring fixes - bad indentation in Args sections --- virtual_ecosystem/models/animal/animal_cohorts.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 2ca4fbd21..80f7c28ee 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -560,7 +560,7 @@ def calculate_total_handling_time_for_herbivory( Args: plant_list: A list of plant resources available for consumption by the - cohort. + cohort. alpha: The search efficiency rate of the herbivore cohort. Returns: @@ -593,9 +593,9 @@ def F_i_k(self, plant_list: list[Resource], target_plant: Resource) -> float: Args: plant_list: A list of plant resources available for consumption by the - cohort. + cohort. target_plant: The specific plant resource being targeted by the herbivore - cohort for consumption. + cohort for consumption. Returns: The instantaneous consumption rate [g/day] of the target plant resource by @@ -1078,7 +1078,7 @@ def get_prey( Args: communities: A dictionary mapping cell IDs to sets of Consumers - (animal cohorts). + (animal cohorts). consumer_cohort: The Consumer for which a prey list is being collected. Returns: @@ -1143,7 +1143,7 @@ def get_excrement_pools( Args: excrement_pools: A dictionary of excrement pools where keys are grid - cell IDs. + cell IDs. Returns: A list of ExcrementPool objects in this territory. @@ -1168,7 +1168,7 @@ def get_herbivory_waste_pools( Args: plant_waste: A dictionary of herbivory waste pools where keys are grid - cell IDs. + cell IDs. Returns: A list of HerbivoryWaste objects in this territory. @@ -1193,7 +1193,7 @@ def get_carcass_pools( Args: carcass_pools: A dictionary of carcass pools where keys are grid - cell IDs. + cell IDs. Returns: A list of CarcassPool objects in this territory. From d343841361b599ce20891d08acb3d1a13cb09bce Mon Sep 17 00:00:00 2001 From: David Orme Date: Wed, 30 Oct 2024 17:05:09 +0000 Subject: [PATCH 59/62] Fixing sphinx issues. Odd ones: uuid.UUID not being resolved, AnimalModel gaining a .random() method --- docs/source/api/models/animal/animal_model.md | 2 +- virtual_ecosystem/models/animal/animal_cohorts.py | 4 ++-- virtual_ecosystem/models/animal/animal_model.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/api/models/animal/animal_model.md b/docs/source/api/models/animal/animal_model.md index 9be3f9af5..8aa8e7577 100644 --- a/docs/source/api/models/animal/animal_model.md +++ b/docs/source/api/models/animal/animal_model.md @@ -30,5 +30,5 @@ language_info: .. automodule:: virtual_ecosystem.models.animal.animal_model :autosummary: :members: - :exclude-members: model_name + :exclude-members: model_name, random ``` diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 80f7c28ee..8e5748ce0 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -2,8 +2,8 @@ from __future__ import annotations +import uuid from math import ceil, exp, sqrt -from uuid import UUID, uuid4 from numpy import timedelta64 @@ -60,7 +60,7 @@ def __init__( """The the grid structure of the simulation.""" self.constants = constants """Animal constants.""" - self.id: UUID = uuid4() + self.id: uuid.UUID = uuid.uuid4() """A unique identifier for the cohort.""" self.damuth_density: int = sf.damuths_law( self.functional_group.adult_mass, self.functional_group.damuths_law_terms diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index b95d42429..818dd990c 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -18,10 +18,10 @@ from __future__ import annotations +import uuid from math import ceil, sqrt from random import choice, random from typing import Any -from uuid import UUID from numpy import array, timedelta64, zeros from xarray import DataArray @@ -190,7 +190,7 @@ def __init__( for cell_id in self.data.grid.cell_id } """A pool for leaves removed by herbivory but not actually consumed.""" - self.cohorts: dict[UUID, AnimalCohort] = {} + self.cohorts: dict[uuid.UUID, AnimalCohort] = {} """A dictionary of all animal cohorts and their unique ids.""" self.communities: dict[int, list[AnimalCohort]] = { cell_id: list() for cell_id in self.data.grid.cell_id From d9f598ba6126047f4c419ff534ece5060ae30f3b Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 3 Dec 2024 11:03:40 +0000 Subject: [PATCH 60/62] Removed input_partition.py as it was no longer in use. --- virtual_ecosystem/models/animal/protocols.py | 1 - .../models/litter/input_partition.py | 202 ------------------ 2 files changed, 203 deletions(-) delete mode 100644 virtual_ecosystem/models/litter/input_partition.py diff --git a/virtual_ecosystem/models/animal/protocols.py b/virtual_ecosystem/models/animal/protocols.py index bcc3cc443..ddd3af1a4 100644 --- a/virtual_ecosystem/models/animal/protocols.py +++ b/virtual_ecosystem/models/animal/protocols.py @@ -5,7 +5,6 @@ from typing import Protocol -# from virtual_ecosystem.models.animal.decay import ExcrementPool from virtual_ecosystem.models.animal.functional_group import FunctionalGroup diff --git a/virtual_ecosystem/models/litter/input_partition.py b/virtual_ecosystem/models/litter/input_partition.py deleted file mode 100644 index 612125db9..000000000 --- a/virtual_ecosystem/models/litter/input_partition.py +++ /dev/null @@ -1,202 +0,0 @@ -"""The ``models.litter.input_partition`` module handles the partitioning of dead plant -matter into the various pools of the litter model. -""" # noqa: D205 - -import numpy as np -from numpy.typing import NDArray - -from virtual_ecosystem.core.logger import LOGGER -from virtual_ecosystem.models.litter.constants import LitterConsts - -# TODO - It makes sense for the animal pools to be handled here, but need to think about -# how the partition works with the plant partition, Animals do not contain lignin, so if -# I used the standard function on animal carcasses and excrement the maximum amount -# (85%) will end up in the metabolic pool, which I think is basically fine, with bones -# not being explicitly modelled I think this is fine. This will have to change once -# bones are included. - - -def calculate_metabolic_proportions_of_input( - leaf_turnover_lignin_proportion: NDArray[np.float32], - reproduct_turnover_lignin_proportion: NDArray[np.float32], - root_turnover_lignin_proportion: NDArray[np.float32], - leaf_turnover_c_n_ratio: NDArray[np.float32], - reproduct_turnover_c_n_ratio: NDArray[np.float32], - root_turnover_c_n_ratio: NDArray[np.float32], - leaf_turnover_c_p_ratio: NDArray[np.float32], - reproduct_turnover_c_p_ratio: NDArray[np.float32], - root_turnover_c_p_ratio: NDArray[np.float32], - constants: LitterConsts, -) -> dict[str, NDArray[np.float32]]: - """Calculate the proportion of each input type that flows to the metabolic pool. - - This function is used for roots, leaves and reproductive tissue, but not deadwood - because everything goes into a single woody litter pool. It is not used for animal - inputs either as they all flow into just the metabolic pool. - - Args: - leaf_turnover_lignin_proportion: Proportion of carbon in turned over leaves that - is lignin [kg lignin kg C^-1] - reproduct_turnover_lignin_proportion: Proportion of carbon in turned over - reproductive tissues that is lignin [kg lignin kg C^-1] - root_turnover_lignin_proportion: Proportion of carbon in turned over roots that - is lignin [kg lignin kg C^-1] - leaf_turnover_c_n_ratio: Carbon:nitrogen ratio of turned over leaves [unitless] - reproduct_turnover_c_n_ratio: Carbon:nitrogen ratio of turned over reproductive - tissues [unitless] - root_turnover_c_n_ratio: Carbon:nitrogen ratio of turned over roots [unitless] - leaf_turnover_c_p_ratio: Carbon:phosphorus ratio of turned over leaves - [unitless] - reproduct_turnover_c_p_ratio: Carbon:phosphorus ratio of turned over - reproductive tissues [unitless] - root_turnover_c_p_ratio: Carbon:phosphorus ratio of turned over roots [unitless] - constants: Set of constants for the litter model. - - Returns: - A dictionary containing the proportion of the input that goes to the relevant - metabolic pool. This is for three input types: leaves, reproductive tissues and - roots [unitless] - """ - - # Calculate split of each input biomass type - leaves_metabolic_split = split_pool_into_metabolic_and_structural_litter( - lignin_proportion=leaf_turnover_lignin_proportion, - carbon_nitrogen_ratio=leaf_turnover_c_n_ratio, - carbon_phosphorus_ratio=leaf_turnover_c_p_ratio, - max_metabolic_fraction=constants.max_metabolic_fraction_of_input, - split_sensitivity_nitrogen=constants.metabolic_split_nitrogen_sensitivity, - split_sensitivity_phosphorus=constants.metabolic_split_phosphorus_sensitivity, - ) - repoduct_metabolic_split = split_pool_into_metabolic_and_structural_litter( - lignin_proportion=reproduct_turnover_lignin_proportion, - carbon_nitrogen_ratio=reproduct_turnover_c_n_ratio, - carbon_phosphorus_ratio=reproduct_turnover_c_p_ratio, - max_metabolic_fraction=constants.max_metabolic_fraction_of_input, - split_sensitivity_nitrogen=constants.metabolic_split_nitrogen_sensitivity, - split_sensitivity_phosphorus=constants.metabolic_split_phosphorus_sensitivity, - ) - roots_metabolic_split = split_pool_into_metabolic_and_structural_litter( - lignin_proportion=root_turnover_lignin_proportion, - carbon_nitrogen_ratio=root_turnover_c_n_ratio, - carbon_phosphorus_ratio=root_turnover_c_p_ratio, - max_metabolic_fraction=constants.max_metabolic_fraction_of_input, - split_sensitivity_nitrogen=constants.metabolic_split_nitrogen_sensitivity, - split_sensitivity_phosphorus=constants.metabolic_split_phosphorus_sensitivity, - ) - - return { - "leaves": leaves_metabolic_split, - "reproductive": repoduct_metabolic_split, - "roots": roots_metabolic_split, - } - - -def partion_plant_inputs_between_pools( - deadwood_production: NDArray[np.float32], - leaf_turnover: NDArray[np.float32], - reproduct_turnover: NDArray[np.float32], - root_turnover: NDArray[np.float32], - metabolic_splits: dict[str, NDArray[np.float32]], -): - """Function to partition input biomass between the various litter pools. - - All deadwood is added to the woody litter pool. Reproductive biomass (fruits and - flowers) and leaves are split between the above ground metabolic and structural - pools based on lignin concentration and carbon nitrogen ratios. Root biomass is - split between the below ground metabolic and structural pools based on lignin - concentration and carbon nitrogen ratios. - - Args: - deadwood_production: Amount of dead wood produced [kg C m^-2] - leaf_turnover: Amount of leaf turnover [kg C m^-2] - reproduct_turnover: Turnover of plant reproductive tissues (i.e. fruits and - flowers) [kg C m^-2] - root_turnover: Turnover of roots (coarse and fine) turnover [kg C m^-2] - metabolic_splits: Dictionary containing the proportion of each input that goes - to the relevant metabolic pool. This is for three input types: leaves, - reproductive tissues and roots [unitless] - - Returns: - A dictionary containing the biomass flow into each of the five litter pools - (woody, above ground metabolic, above ground structural, below ground metabolic - and below ground structural) - """ - - # Calculate input to each of the five litter pools - woody_input = deadwood_production - above_ground_metabolic_input = ( - metabolic_splits["leaves"] * leaf_turnover - + metabolic_splits["reproductive"] * reproduct_turnover - ) - above_ground_strutural_input = ( - (1 - metabolic_splits["leaves"]) * leaf_turnover - + (1 - metabolic_splits["reproductive"]) * reproduct_turnover - ) # fmt: off - below_ground_metabolic_input = metabolic_splits["roots"] * root_turnover - below_ground_structural_input = (1 - metabolic_splits["roots"]) * root_turnover - - return { - "woody": woody_input, - "above_ground_metabolic": above_ground_metabolic_input, - "above_ground_structural": above_ground_strutural_input, - "below_ground_metabolic": below_ground_metabolic_input, - "below_ground_structural": below_ground_structural_input, - } - - -def split_pool_into_metabolic_and_structural_litter( - lignin_proportion: NDArray[np.float32], - carbon_nitrogen_ratio: NDArray[np.float32], - carbon_phosphorus_ratio: NDArray[np.float32], - max_metabolic_fraction: float, - split_sensitivity_nitrogen: float, - split_sensitivity_phosphorus: float, -) -> NDArray[np.float32]: - """Calculate the split of input biomass between metabolic and structural pools. - - This division depends on the lignin and nitrogen content of the input biomass, the - functional form is taken from :cite:t:`parton_dynamics_1988`. - - Args: - lignin_proportion: Proportion of input biomass carbon that is lignin [kg lignin - kg C^-1] - carbon_nitrogen_ratio: Ratio of carbon to nitrogen for the input biomass - [unitless] - carbon_phosphorus_ratio: Ratio of carbon to phosphorus for the input biomass - [unitless] - max_metabolic_fraction: Fraction of pool that becomes metabolic litter for the - easiest to breakdown case, i.e. no lignin, ample nitrogen [unitless] - split_sensitivity_nitrogen: Sets how rapidly the split changes in response to - changing lignin and nitrogen contents [unitless] - split_sensitivity_phosphorus: Sets how rapidly the split changes in response to - changing lignin and phosphorus contents [unitless] - - Raises: - ValueError: If any of the metabolic fractions drop below zero, or if any - structural fraction is less than the lignin proportion (which would push the - lignin proportion of the structural litter input above 100%). - - Returns: - The fraction of the biomass that goes to the metabolic pool [unitless] - """ - - metabolic_fraction = max_metabolic_fraction - lignin_proportion * ( - split_sensitivity_nitrogen * carbon_nitrogen_ratio - + split_sensitivity_phosphorus * carbon_phosphorus_ratio - ) - - if np.any(metabolic_fraction < 0.0): - to_raise = ValueError( - "Fraction of input biomass going to metabolic pool has dropped below zero!" - ) - LOGGER.error(to_raise) - raise to_raise - elif np.any(1 - metabolic_fraction < lignin_proportion): - to_raise = ValueError( - "Fraction of input biomass going to structural biomass is less than the " - "lignin fraction!" - ) - LOGGER.error(to_raise) - raise to_raise - else: - return metabolic_fraction From 1bed87b43b3b430378fd2c7553a0f7cb10cd93c2 Mon Sep 17 00:00:00 2001 From: Jacob Cook Date: Tue, 3 Dec 2024 14:10:48 +0000 Subject: [PATCH 61/62] Added helper functions to reset the decomposed components of the carcass and excrement pools --- tests/models/animals/test_decay.py | 44 +++++++++++++++++++ .../models/animal/animal_model.py | 8 +--- virtual_ecosystem/models/animal/decay.py | 24 ++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/tests/models/animals/test_decay.py b/tests/models/animals/test_decay.py index 94dcf204f..8dc9b46e7 100644 --- a/tests/models/animals/test_decay.py +++ b/tests/models/animals/test_decay.py @@ -42,6 +42,28 @@ def test_initialization(self): with pytest.raises(AttributeError): carcasses.decomposed_nutrient_per_area("molybdenum", 10000) + def test_reset(self): + """Test resetting of the carcass pool.""" + + from virtual_ecosystem.models.animal.decay import CarcassPool + + carcasses = CarcassPool( + scavengeable_carbon=1.0007e-2, + decomposed_carbon=2.5e-5, + scavengeable_nitrogen=0.000133333332, + decomposed_nitrogen=3.3333333e-6, + scavengeable_phosphorus=1.33333332e-6, + decomposed_phosphorus=3.3333333e-8, + ) + carcasses.reset() + + assert pytest.approx(carcasses.scavengeable_carbon) == 1.0007e-2 + assert pytest.approx(carcasses.scavengeable_nitrogen) == 0.000133333332 + assert pytest.approx(carcasses.scavengeable_phosphorus) == 1.33333332e-6 + assert pytest.approx(carcasses.decomposed_carbon) == 0.0 + assert pytest.approx(carcasses.decomposed_nitrogen) == 0.0 + assert pytest.approx(carcasses.decomposed_phosphorus) == 0.0 + class TestExcrementPool: """Test the ExcrementPool class.""" @@ -79,6 +101,28 @@ def test_initialization(self): with pytest.raises(AttributeError): poo.decomposed_nutrient_per_area("molybdenum", 10000) + def test_reset(self): + """Test resetting of the excrement pool.""" + + from virtual_ecosystem.models.animal.decay import ExcrementPool + + poo = ExcrementPool( + scavengeable_carbon=7.77e-5, + decomposed_carbon=2.5e-5, + scavengeable_nitrogen=1e-5, + decomposed_nitrogen=3.3333333e-6, + scavengeable_phosphorus=1e-7, + decomposed_phosphorus=3.3333333e-8, + ) + poo.reset() + + assert pytest.approx(poo.scavengeable_carbon) == 7.77e-5 + assert pytest.approx(poo.scavengeable_nitrogen) == 1e-5 + assert pytest.approx(poo.scavengeable_phosphorus) == 1e-7 + assert pytest.approx(poo.decomposed_carbon) == 0.0 + assert pytest.approx(poo.decomposed_nitrogen) == 0.0 + assert pytest.approx(poo.decomposed_phosphorus) == 0.0 + @pytest.mark.parametrize( argnames=[ diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 818dd990c..5afe7ce1a 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -533,15 +533,11 @@ def calculate_soil_additions(self) -> dict[str, DataArray]: # Reset all decomposed excrement pools to zero for excrement_pools in self.excrement_pools.values(): for excrement_pool in excrement_pools: - excrement_pool.decomposed_carbon = 0.0 - excrement_pool.decomposed_nitrogen = 0.0 - excrement_pool.decomposed_phosphorus = 0.0 + excrement_pool.reset() for carcass_pools in self.carcass_pools.values(): for carcass_pool in carcass_pools: - carcass_pool.decomposed_carbon = 0.0 - carcass_pool.decomposed_nitrogen = 0.0 - carcass_pool.decomposed_phosphorus = 0.0 + carcass_pool.reset() # Create the output DataArray for each nutrient return { diff --git a/virtual_ecosystem/models/animal/decay.py b/virtual_ecosystem/models/animal/decay.py index 533b095d9..a30f561f2 100644 --- a/virtual_ecosystem/models/animal/decay.py +++ b/virtual_ecosystem/models/animal/decay.py @@ -56,6 +56,18 @@ def decomposed_nutrient_per_area( return decomposed_nutrient / grid_cell_area + def reset(self): + """Reset tracking of the nutrients associated with decomposed carcasses. + + This function sets the amount of decomposed carbon, nitrogen and phosphorus to + zero. This function should only be called after transfers to the soil model due + to decomposition have been calculated. + """ + + self.decomposed_carbon = 0.0 + self.decomposed_nitrogen = 0.0 + self.decomposed_phosphorus = 0.0 + @dataclass class ExcrementPool: @@ -101,6 +113,18 @@ def decomposed_nutrient_per_area( return decomposed_nutrient / grid_cell_area + def reset(self): + """Reset tracking of the nutrients associated with decomposed excrement. + + This function sets the amount of decomposed carbon, nitrogen and phosphorus to + zero. This function should only be called after transfers to the soil model due + to decomposition have been calculated. + """ + + self.decomposed_carbon = 0.0 + self.decomposed_nitrogen = 0.0 + self.decomposed_phosphorus = 0.0 + def find_decay_consumed_split( microbial_decay_rate: float, animal_scavenging_rate: float From 509f7d7cb3cfc9529bc92775e2a34f718d7d618c Mon Sep 17 00:00:00 2001 From: Taran Rallings Date: Tue, 3 Dec 2024 15:56:16 +0000 Subject: [PATCH 62/62] Removed vestigal damuths law in AnimalCohort. --- virtual_ecosystem/models/animal/animal_cohorts.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index 8e5748ce0..5410fae63 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -62,9 +62,9 @@ def __init__( """Animal constants.""" self.id: uuid.UUID = uuid.uuid4() """A unique identifier for the cohort.""" - self.damuth_density: int = sf.damuths_law( - self.functional_group.adult_mass, self.functional_group.damuths_law_terms - ) + # self.damuth_density: int = sf.damuths_law( + # self.functional_group.adult_mass, self.functional_group.damuths_law_terms + # ) """The number of individuals in an average cohort of this type.""" self.is_alive: bool = True """Whether the cohort is alive [True] or dead [False]."""