diff --git a/docs/source/_toc.yaml b/docs/source/_toc.yaml index d7e57db50..000cc5b87 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 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/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..8dc9b46e7 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.""" @@ -46,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.""" @@ -83,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=[ @@ -151,36 +191,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/data_variables.toml b/virtual_ecosystem/data_variables.toml index 22b0d859e..c3ad17371 100644 --- a/virtual_ecosystem/data_variables.toml +++ b/virtual_ecosystem/data_variables.toml @@ -1025,4 +1025,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 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 ded76ab43..5410fae63 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 +import uuid from math import ceil, exp, sqrt 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,11 +54,17 @@ 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.damuth_density: int = sf.damuths_law( - self.functional_group.adult_mass, self.functional_group.damuths_law_terms - ) + 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 + # ) """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].""" @@ -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 + 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 ) - # 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 - ) - 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) - # 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 carcass mass per pool + carcass_mass_per_pool = carcass_mass / number_carcass_pools - # 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 += ( + # 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 - 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,10 +556,11 @@ 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 - cohort. + plant_list: A list of plant resources available for consumption by the + cohort. alpha: The search efficiency rate of the herbivore cohort. Returns: @@ -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,10 +592,10 @@ def F_i_k( TODO: update name Args: - plant_list: A sequence of plant resources available for consumption by the - cohort. + 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. + cohort for consumption. Returns: The instantaneous consumption rate [g/day] of the target plant resource by @@ -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..5afe7ce1a 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -18,7 +18,9 @@ from __future__ import annotations -from math import sqrt +import uuid +from math import ceil, sqrt +from random import choice, random from typing import Any from numpy import array, timedelta64, zeros @@ -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.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,43 @@ 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.reset() + + for carcass_pools in self.carcass_pools.values(): + for carcass_pool in carcass_pools: + carcass_pool.reset() + # Create the output DataArray for each nutrient return { "decomposed_excrement_carbon": DataArray( array(decomposed_excrement["carbon"]) @@ -501,16 +577,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 +615,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/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 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