diff --git a/tests/models/animals/conftest.py b/tests/models/animals/conftest.py index e0be49e57..1b407b4a2 100644 --- a/tests/models/animals/conftest.py +++ b/tests/models/animals/conftest.py @@ -787,6 +787,39 @@ def herbivore_cohort_instance( ) +@pytest.fixture +def fungivore_functional_group_instance(shared_datadir, constants_instance): + """Fixture for an animal functional group used in tests.""" + from virtual_ecosystem.models.animal.functional_group import ( + import_functional_groups, + ) + + file = shared_datadir / "example_functional_group_import.csv" + fg_list = import_functional_groups(file, constants_instance) + + return fg_list[16] + + +@pytest.fixture +def fungivore_cohort_instance( + fungivore_functional_group_instance, + animal_data_for_cohorts_instance, + constants_instance, +): + """Fixture for an animal cohort used in tests.""" + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + + return AnimalCohort( + fungivore_functional_group_instance, + 10000.0, + 1, + 10, + 1, # centroid + animal_data_for_cohorts_instance.grid, # grid + constants_instance, + ) + + @pytest.fixture def predator_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" @@ -820,6 +853,39 @@ def predator_cohort_instance( ) +@pytest.fixture +def earthworm_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[12] + + +@pytest.fixture +def earthworm_cohort_instance( + earthworm_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( + earthworm_functional_group_instance, + 1.0, + 1, + 100, + 1, # centroid + animal_data_for_cohorts_instance.grid, + constants_instance, + ) + + @pytest.fixture def caterpillar_functional_group_instance(shared_datadir, constants_instance): """Fixture for an animal functional group used in tests.""" @@ -1108,3 +1174,152 @@ def herbivory_waste_pool_instance(): ) return herbivory_waste + + +@pytest.fixture +def mushroom_instance(litter_soil_data_instance): + """Fixture for a single FungalFruitPool object.""" + from virtual_ecosystem.models.animal.decay import ( + FungalFruitPool, + ) # Adjust as needed + + return FungalFruitPool( + cell_id=0, + data=litter_soil_data_instance, + cell_area=100.0, # m² + c_n_ratio=25.0, + c_p_ratio=100.0, + ) + + +@pytest.fixture +def fungal_fruit_list_instance(litter_soil_data_instance): + """Fixture for multiple FungalFruitPool objects across grid cells.""" + from virtual_ecosystem.models.animal.decay import FungalFruitPool + + return [ + FungalFruitPool( + cell_id=cell_id, + data=litter_soil_data_instance, + cell_area=100.0, + c_n_ratio=25.0, + c_p_ratio=100.0, + ) + for cell_id in litter_soil_data_instance.grid.cell_id + ] + + +@pytest.fixture +def microbial_cnp_ratios() -> dict[str, dict[str, float]]: + """Reusable microbial C:N:P ratios for SoilPool construction. + + Returns: + Mapping from pool name to C:N and C:P ratios (unitless). Used by + SoilPool for bacteria and fungi stoichiometry. + """ + + return { + "bacteria": {"nitrogen": 5.0, "phosphorus": 30.0}, + "saprotrophic_fungi": {"nitrogen": 10.0, "phosphorus": 80.0}, + "arbuscular_mycorrhiza": {"nitrogen": 12.0, "phosphorus": 90.0}, + "ectomycorrhiza": {"nitrogen": 8.0, "phosphorus": 70.0}, + } + + +@pytest.fixture +def soil_fungi_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for a single SoilPool 'fungi' object.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return SoilPool( + pool_name="fungi", + cell_id=0, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + + +@pytest.fixture +def soil_fungi_list_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for SoilPool 'fungi' objects across all grid cells.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return [ + SoilPool( + pool_name="fungi", + cell_id=cell_id, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + for cell_id in litter_soil_data_instance.grid.cell_id + ] + + +@pytest.fixture +def pom_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for a single SoilPool 'pom' object.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return SoilPool( + pool_name="pom", + cell_id=0, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + + +@pytest.fixture +def pom_list_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for SoilPool 'pom' objects across all grid cells.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return [ + SoilPool( + pool_name="pom", + cell_id=cell_id, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + for cell_id in litter_soil_data_instance.grid.cell_id + ] + + +@pytest.fixture +def bacteria_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for a single SoilPool 'bacteria' object.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return SoilPool( + pool_name="bacteria", + cell_id=0, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + + +@pytest.fixture +def bacteria_list_instance(litter_soil_data_instance, microbial_cnp_ratios): + """Fixture for SoilPool 'bacteria' objects across all grid cells.""" + from virtual_ecosystem.models.animal.decay import SoilPool + + return [ + SoilPool( + pool_name="bacteria", + cell_id=cell_id, + data=litter_soil_data_instance, + cell_area=litter_soil_data_instance.grid.cell_area, + max_depth_microbial_activity=0.2, + c_n_p_ratios=microbial_cnp_ratios, + ) + for cell_id in litter_soil_data_instance.grid.cell_id + ] diff --git a/tests/models/animals/data/example_functional_group_import.csv b/tests/models/animals/data/example_functional_group_import.csv index c7fd3ea11..cb493344b 100644 --- a/tests/models/animals/data/example_functional_group_import.csv +++ b/tests/models/animals/data/example_functional_group_import.csv @@ -11,7 +11,8 @@ butterfly,invertebrate,foliage_fruit,ectothermic,terrestrial,semelparous,indirec caterpillar,invertebrate,foliage_fruit,ectothermic,terrestrial,nonreproductive,indirect,larval,butterfly,uricotelic,none,canopy,0.0005,0.005, 0.005 frog,amphibian,vertebrates_invertebrates_carcasses,ectothermic,aquatic,iteroparous,direct,adult,frog,ureotelic,none,ground,0.005,0.5, 0.005 swallow,bird,invertebrates,endothermic,terrestrial,iteroparous,direct,adult,swallow,uricotelic,seasonal,canopy,0.005,0.2, 0.005 -earthworm,invertebrate,detritus,ectothermic,terrestrial,iteroparous,direct,adult,earthworm,uricotelic,none,soil,0.0005,0.005, 0.005 +earthworm,invertebrate,detritus_fungi_pom_bacteria,ectothermic,terrestrial,iteroparous,direct,adult,earthworm,uricotelic,none,soil,0.0005,0.005, 0.005 dung_beetle,invertebrate,waste,ectothermic,terrestrial,iteroparous,direct,adult,dung_beetle,uricotelic,none,soil_ground,0.0003,0.003, 0.005 scavenging_mammal,mammal,carcasses,endothermic,terrestrial,iteroparous,direct,adult,scavenging_mammal,ureotelic,none,ground,2.0,20.0, 0.005 detritivorous_insect,invertebrate,detritus,ectothermic,terrestrial,iteroparous,direct,adult,detritivorous_insect,uricotelic,none,soil_ground,0.0004,0.004, 0.005 +fungivorous_mammal,mammal,mushrooms,endothermic,terrestrial,iteroparous,direct,adult,fungivorous_mammal,ureotelic,none,soil_ground,1.0,10.0, 0.005 diff --git a/tests/models/animals/test_animal_cohorts.py b/tests/models/animals/test_animal_cohorts.py index 65f13a652..64c46a0cc 100644 --- a/tests/models/animals/test_animal_cohorts.py +++ b/tests/models/animals/test_animal_cohorts.py @@ -1863,8 +1863,115 @@ def test_delta_mass_excrement_scavenging_calls_forage_resource_list( assert result == {"carbon": 4.0, "nitrogen": 1.0, "phosphorus": 0.5} + def test_delta_mass_fruiting_fungivory_calls_forage_resource_list( + self, herbivore_cohort_instance, mocker + ): + """Test fruiting fungivory wrapper delegates to forage_resource_list.""" + cohort = herbivore_cohort_instance + fruits = [mocker.Mock()] + waste_pools = {0: mocker.Mock()} + + mock_forage = mocker.patch.object( + cohort, + "forage_resource_list", + return_value={"carbon": 1, "nitrogen": 2, "phosphorus": 3}, + ) + + result = cohort.delta_mass_fruiting_fungivory( + fungal_fruit_list=fruits, + adjusted_dt=5.0, + herbivory_waste_pools=waste_pools, + ) + + mock_forage.assert_called_once_with( + resources=fruits, + adjusted_dt=5.0, + calculate_consumed_mass=cohort._consumed_resource_mass, + herbivory_waste_pools=waste_pools, + ) + assert result == {"carbon": 1, "nitrogen": 2, "phosphorus": 3} + + def test_delta_mass_soil_fungivory_calls_forage_resource_list( + self, herbivore_cohort_instance, mocker + ): + """Test soil fungivory wrapper delegates to forage_resource_list.""" + cohort = herbivore_cohort_instance + fungi = [mocker.Mock()] + + mock_forage = mocker.patch.object( + cohort, + "forage_resource_list", + return_value={"carbon": 4, "nitrogen": 5, "phosphorus": 6}, + ) + + result = cohort.delta_mass_soil_fungivory( + soil_fungi_list=fungi, + adjusted_dt=3.25, + ) + + mock_forage.assert_called_once_with( + resources=fungi, + adjusted_dt=3.25, + calculate_consumed_mass=cohort._consumed_resource_mass, + herbivory_waste_pools=None, + ) + assert result == {"carbon": 4, "nitrogen": 5, "phosphorus": 6} + + def test_delta_mass_pomivory_calls_forage_resource_list( + self, herbivore_cohort_instance, mocker + ): + """Test pomivory wrapper delegates to forage_resource_list.""" + cohort = herbivore_cohort_instance + poms = [mocker.Mock()] + + mock_forage = mocker.patch.object( + cohort, + "forage_resource_list", + return_value={"carbon": 7, "nitrogen": 8, "phosphorus": 9}, + ) + + result = cohort.delta_mass_pomivory( + pom_list=poms, + adjusted_dt=2.0, + ) + + mock_forage.assert_called_once_with( + resources=poms, + adjusted_dt=2.0, + calculate_consumed_mass=cohort._consumed_resource_mass, + herbivory_waste_pools=None, + ) + assert result == {"carbon": 7, "nitrogen": 8, "phosphorus": 9} + + def test_delta_mass_bacteriophagy_calls_forage_resource_list( + self, herbivore_cohort_instance, mocker + ): + """Test bacteriophagy wrapper delegates to forage_resource_list.""" + cohort = herbivore_cohort_instance + bacteria = [mocker.Mock()] + + mock_forage = mocker.patch.object( + cohort, + "forage_resource_list", + return_value={"carbon": 10, "nitrogen": 11, "phosphorus": 12}, + ) + + result = cohort.delta_mass_bacteriophagy( + bacteria_list=bacteria, + adjusted_dt=1.5, + ) + + mock_forage.assert_called_once_with( + resources=bacteria, + adjusted_dt=1.5, + calculate_consumed_mass=cohort._consumed_resource_mass, + herbivory_waste_pools=None, + ) + assert result == {"carbon": 10, "nitrogen": 11, "phosphorus": 12} + @pytest.mark.parametrize( - "cohort_instance, diet_type, plant_list, animal_list, expected_nutrient_gain," + "cohort_instance, diet_type, plant_list, animal_list, fungal_fruit_list," + "soil_fungi_list,pom_list, bacteria_list, expected_nutrient_gain," "delta_mass_mock", [ ( @@ -1872,6 +1979,10 @@ def test_delta_mass_excrement_scavenging_calls_forage_resource_list( "HERBIVORE", "plant_list_instance", [], + [], + [], + [], + [], {"carbon": 60.0, "nitrogen": 30.0, "phosphorus": 10.0}, "delta_mass_herbivory", ), @@ -1880,10 +1991,27 @@ def test_delta_mass_excrement_scavenging_calls_forage_resource_list( "CARNIVORE", [], "animal_list_instance", + [], + [], + [], + [], {"carbon": 120.0, "nitrogen": 60.0, "phosphorus": 20.0}, "delta_mass_predation", ), + ( + "fungivore_cohort_instance", + "MUSHROOMS", + [], + [], + "fungal_fruit_list_instance", + [], + [], + [], + {"carbon": 25.0, "nitrogen": 5.0, "phosphorus": 2.5}, + "delta_mass_fruiting_fungivory", + ), ], + ids=["herbivore", "carnivore", "fungivore"], ) def test_forage_cohort( self, @@ -1893,10 +2021,18 @@ def test_forage_cohort( diet_type, plant_list, animal_list, + fungal_fruit_list, + soil_fungi_list, + pom_list, + bacteria_list, expected_nutrient_gain, delta_mass_mock, plant_list_instance, animal_list_instance, + fungal_fruit_list_instance, + soil_fungi_list_instance, + pom_list_instance, + bacteria_list_instance, excrement_pool_instance, carcass_pools_by_cell_instance, herbivory_waste_pool_instance, @@ -1913,6 +2049,8 @@ def test_forage_cohort( plant_list = request.getfixturevalue(plant_list) if isinstance(animal_list, str): animal_list = request.getfixturevalue(animal_list) + if isinstance(fungal_fruit_list, str): + fungal_fruit_list = request.getfixturevalue(fungal_fruit_list) # Construct herbivory waste pools if herbivore herbivory_waste_pools = { @@ -1924,7 +2062,6 @@ def test_forage_cohort( mock_delta_mass = mocker.patch.object( cohort, delta_mass_mock, return_value=expected_nutrient_gain ) - mock_eat = mocker.patch.object(cohort, "eat") # Dummy values for untested inputs empty_list = [] @@ -1933,8 +2070,12 @@ def test_forage_cohort( cohort.forage_cohort( plant_list=plant_list, animal_list=animal_list, + fungal_fruit_list=fungal_fruit_list, + soil_fungi_list=soil_fungi_list, + pom_list=pom_list, + bacteria_list=bacteria_list, litter_pools=empty_list, - excrement_pools=excrement_pool_instance, + excrement_pools=[excrement_pool_instance], carcass_pool_map=carcass_pools_by_cell_instance, scavenge_carcass_pools=empty_list, scavenge_excrement_pools=empty_list, @@ -1953,16 +2094,84 @@ def test_forage_cohort( assert kwargs["herbivory_waste_pools"] == herbivory_waste_pools assert isinstance(kwargs["adjusted_dt"], int | float) - else: + elif diet_type == "CARNIVORE": assert kwargs["animal_list"] == animal_list_instance assert kwargs["carcass_pools"] == carcass_pools_by_cell_instance assert isinstance(kwargs["adjusted_dt"], int | float) - # Validate assimilation call - mock_eat.assert_called_once_with( - expected_nutrient_gain, excrement_pool_instance + elif diet_type == "MUSHROOMS": + assert kwargs["fungal_fruit_list"] == fungal_fruit_list_instance + assert isinstance(kwargs["adjusted_dt"], int | float) + + else: + assert False, f"Unhandled diet_type: {diet_type}" + + def test_forage_cohort_earthworm_multisoil( + self, + mocker, + earthworm_cohort_instance, + soil_fungi_list_instance, + pom_list_instance, + bacteria_list_instance, + excrement_pool_instance, + carcass_pools_by_cell_instance, + ): + """Ensure composite diet routes to all four paths.""" + # Imports inside test per project rules. + from virtual_ecosystem.models.animal.animal_traits import DietType + + cohort = earthworm_cohort_instance + cohort.functional_group.diet = DietType.parse("detritus_fungi_pom_bacteria") + + # Patch delta-mass methods to observe calls and avoid side effects. + expected = {"carbon": 1.0, "nitrogen": 0.5, "phosphorus": 0.1} + m_det = mocker.patch.object( + cohort, "delta_mass_detritivory", return_value=expected + ) + m_fungi = mocker.patch.object( + cohort, "delta_mass_soil_fungivory", return_value=expected + ) + m_pom = mocker.patch.object( + cohort, "delta_mass_pomivory", return_value=expected + ) + m_bact = mocker.patch.object( + cohort, "delta_mass_bacteriophagy", return_value=expected + ) + + # Litter must be non-empty so detritivory path is exercised. + litter_pools = ["litter_resources"] + + cohort.forage_cohort( + plant_list=[], + animal_list=[], + fungal_fruit_list=[], + soil_fungi_list=soil_fungi_list_instance, + pom_list=pom_list_instance, + bacteria_list=bacteria_list_instance, + litter_pools=litter_pools, + excrement_pools=[excrement_pool_instance], + carcass_pool_map=carcass_pools_by_cell_instance, + scavenge_carcass_pools=[], + scavenge_excrement_pools=[], + herbivory_waste_pools={}, + dt=30, ) + # Each relevant path should be called exactly once with correct args. + m_det.assert_called_once() + m_fungi.assert_called_once() + m_pom.assert_called_once() + m_bact.assert_called_once() + + assert m_det.call_args.kwargs["litter_pools"] == litter_pools + assert m_fungi.call_args.kwargs["soil_fungi_list"] == soil_fungi_list_instance + assert m_pom.call_args.kwargs["pom_list"] == pom_list_instance + assert m_bact.call_args.kwargs["bacteria_list"] == bacteria_list_instance + + # Basic sanity: adjusted_dt is numeric for each call. + for m in (m_det, m_fungi, m_pom, m_bact): + assert isinstance(m.call_args.kwargs["adjusted_dt"], int | float) + def test_forage_cohort_skips_when_no_individuals( self, mocker, herbivore_cohort_instance ): @@ -1981,6 +2190,10 @@ def test_forage_cohort_skips_when_no_individuals( cohort.forage_cohort( plant_list=[], animal_list=[], + fungal_fruit_list=[], + soil_fungi_list=[], + pom_list=[], + bacteria_list=[], litter_pools=[], excrement_pools=[], carcass_pool_map={}, @@ -2011,6 +2224,10 @@ def test_forage_cohort_skips_when_no_mass(self, mocker, herbivore_cohort_instanc cohort.forage_cohort( plant_list=[], animal_list=[], + fungal_fruit_list=[], + soil_fungi_list=[], + pom_list=[], + bacteria_list=[], litter_pools=[], excrement_pools=[], carcass_pool_map={}, @@ -2719,6 +2936,289 @@ def test_get_carcass_pools( assert len(result) == expected + @pytest.mark.parametrize( + "territory, cell_fruit_map, expected", + [ + # Single valid fruiting pool + ([1], {1: "valid"}, 1), + # Valid and invalid in separate cells + ([1, 2], {1: "valid", 2: "invalid"}, 1), + # All invalid + ([1, 2], {1: "invalid", 2: "invalid"}, 0), + # Multiple valid across cells + ([1, 2], {1: "valid", 2: "valid"}, 2), + # Territory includes a cell with no pool + ([1, 2], {1: "valid"}, 1), + ], + ) + def test_get_fungal_fruit_pools( + self, + territory, + cell_fruit_map, + expected, + functional_group_list_instance, + constants_instance, + ): + """Test get_fungal_fruit_pools with singleton-per-cell mapping.""" + + from types import SimpleNamespace + + from virtual_ecosystem.core.grid import Grid + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Setup grid and functional group + grid = Grid(grid_type="square", cell_nx=3, cell_ny=3) + herbivore_group = get_functional_group_by_name( + functional_group_list_instance, "herbivorous_mammal" + ) + + # Create dummy cohort with defined territory + cohort = AnimalCohort( + functional_group=herbivore_group, + mass=10.0, + age=20.0, + individuals=10, + centroid_key=0, + grid=grid, + constants=constants_instance, + ) + cohort.territory = territory + + # Build singleton-per-cell dict of "fungal fruiting pools". + # We use simple objects to avoid needing real Data; only identity matters. + fungal_fruiting_bodies: dict[int, object] = {} + all_fruits: list[tuple[object, bool]] = [] + + for cell_id, label in cell_fruit_map.items(): + fruit = SimpleNamespace(cell_id=cell_id) + fungal_fruiting_bodies[cell_id] = fruit + all_fruits.append((fruit, label == "valid")) + + # Filter: keep only objects flagged "valid" above. + cohort.can_forage_on = lambda resource: any( + resource is res and is_valid for res, is_valid in all_fruits + ) + + result = cohort.get_fungal_fruit_pools(fungal_fruiting_bodies) + assert len(result) == expected + + @pytest.mark.parametrize( + "territory, cell_soil_map, expected", + [ + # Single valid fungi pool + ([1], {1: "valid"}, 1), + # Valid and invalid in separate cells + ([1, 2], {1: "valid", 2: "invalid"}, 1), + # All invalid + ([1, 2], {1: "invalid", 2: "invalid"}, 0), + # Multiple valid across cells + ([1, 2], {1: "valid", 2: "valid"}, 2), + # Territory includes a cell with no fungi pool entry + ([1, 2], {1: "valid"}, 1), + ], + ) + def test_get_soil_fungi_pools( + self, + territory, + cell_soil_map, + expected, + functional_group_list_instance, + constants_instance, + ): + """Test get_soil_fungi_pools with per-cell dict[str, SoilPool] mapping. + + Builds a singleton 'fungi' entry per cell using simple objects and filters + with `can_forage_on` to count only those marked as valid. + """ + + from types import SimpleNamespace + + from virtual_ecosystem.core.grid import Grid + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Setup grid and functional group + grid = Grid(grid_type="square", cell_nx=3, cell_ny=3) + herbivore_group = get_functional_group_by_name( + functional_group_list_instance, "herbivorous_mammal" + ) + + # Create dummy cohort with defined territory + cohort = AnimalCohort( + functional_group=herbivore_group, + mass=10.0, + age=20.0, + individuals=10, + centroid_key=0, + grid=grid, + constants=constants_instance, + ) + cohort.territory = territory + + # Build soil_pools: dict[int, dict[str, SoilPool-like]] + soil_pools: dict[int, dict[str, object]] = {} + all_fungi: list[tuple[object, bool]] = [] + + for cell_id, label in cell_soil_map.items(): + pool = SimpleNamespace(cell_id=cell_id) + soil_pools[cell_id] = {"fungi": pool} + all_fungi.append((pool, label == "valid")) + + # Filter: keep only objects flagged "valid" above + cohort.can_forage_on = lambda resource: any( + resource is res and is_valid for res, is_valid in all_fungi + ) + + result = cohort.get_soil_fungi_pools(soil_pools) + assert len(result) == expected + + @pytest.mark.parametrize( + "territory, cell_soil_map, expected", + [ + # Single valid POM pool + ([1], {1: "valid"}, 1), + # Valid and invalid in separate cells + ([1, 2], {1: "valid", 2: "invalid"}, 1), + # All invalid + ([1, 2], {1: "invalid", 2: "invalid"}, 0), + # Multiple valid across cells + ([1, 2], {1: "valid", 2: "valid"}, 2), + # Territory includes a cell with no POM entry + ([1, 2], {1: "valid"}, 1), + ], + ) + def test_get_pom_pools( + self, + territory, + cell_soil_map, + expected, + functional_group_list_instance, + constants_instance, + ): + """Test get_pom_pools with per-cell dict[str, SoilPool] mapping. + + Builds a singleton 'pom' entry per cell using simple objects and filters with + `can_forage_on` to count only those marked as valid. + """ + from types import SimpleNamespace + + from virtual_ecosystem.core.grid import Grid + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Setup grid and functional group + grid = Grid(grid_type="square", cell_nx=3, cell_ny=3) + herbivore_group = get_functional_group_by_name( + functional_group_list_instance, "herbivorous_mammal" + ) + + # Create dummy cohort with defined territory + cohort = AnimalCohort( + functional_group=herbivore_group, + mass=10.0, + age=20.0, + individuals=10, + centroid_key=0, + grid=grid, + constants=constants_instance, + ) + cohort.territory = territory + + # Build soil_pools: dict[int, dict[str, SoilPool-like]] + soil_pools: dict[int, dict[str, object]] = {} + all_pom: list[tuple[object, bool]] = [] + + for cell_id, label in cell_soil_map.items(): + pool = SimpleNamespace(cell_id=cell_id) + soil_pools[cell_id] = {"pom": pool} + all_pom.append((pool, label == "valid")) + + # Filter: keep only objects flagged "valid" above + cohort.can_forage_on = lambda resource: any( + resource is res and is_valid for res, is_valid in all_pom + ) + + result = cohort.get_pom_pools(soil_pools) + assert len(result) == expected + + @pytest.mark.parametrize( + "territory, cell_soil_map, expected", + [ + # Single valid bacteria pool + ([1], {1: "valid"}, 1), + # Valid and invalid in separate cells + ([1, 2], {1: "valid", 2: "invalid"}, 1), + # All invalid + ([1, 2], {1: "invalid", 2: "invalid"}, 0), + # Multiple valid across cells + ([1, 2], {1: "valid", 2: "valid"}, 2), + # Territory includes a cell with no bacteria entry + ([1, 2], {1: "valid"}, 1), + ], + ) + def test_get_bacteria_pools( + self, + territory, + cell_soil_map, + expected, + functional_group_list_instance, + constants_instance, + ): + """Test get_bacteria_pools with per-cell dict[str, SoilPool] mapping. + + Builds a singleton 'bacteria' entry per cell using simple objects and filters + with `can_forage_on` to count only those marked as valid. + """ + from types import SimpleNamespace + + from virtual_ecosystem.core.grid import Grid + from virtual_ecosystem.models.animal.animal_cohorts import AnimalCohort + from virtual_ecosystem.models.animal.functional_group import ( + get_functional_group_by_name, + ) + + # Setup grid and functional group + grid = Grid(grid_type="square", cell_nx=3, cell_ny=3) + herbivore_group = get_functional_group_by_name( + functional_group_list_instance, "herbivorous_mammal" + ) + + # Create dummy cohort with defined territory + cohort = AnimalCohort( + functional_group=herbivore_group, + mass=10.0, + age=20.0, + individuals=10, + centroid_key=0, + grid=grid, + constants=constants_instance, + ) + cohort.territory = territory + + # Build soil_pools: dict[int, dict[str, SoilPool-like]] + soil_pools: dict[int, dict[str, object]] = {} + all_bacteria: list[tuple[object, bool]] = [] + + for cell_id, label in cell_soil_map.items(): + pool = SimpleNamespace(cell_id=cell_id) + soil_pools[cell_id] = {"bacteria": pool} + all_bacteria.append((pool, label == "valid")) + + # Filter: keep only objects flagged "valid" above + cohort.can_forage_on = lambda resource: any( + resource is res and is_valid for res, is_valid in all_bacteria + ) + + result = cohort.get_bacteria_pools(soil_pools) + assert len(result) == expected + @pytest.mark.parametrize( "cohort_occupancy, resource_occupancy, expected", [ diff --git a/tests/models/animals/test_animal_model.py b/tests/models/animals/test_animal_model.py index 4802fbaa5..a7db04add 100644 --- a/tests/models/animals/test_animal_model.py +++ b/tests/models/animals/test_animal_model.py @@ -2087,6 +2087,28 @@ def test_forage_community( "predator": predator_cohort_instance, } + # Show where AnimalModel comes from (guards against stray imports) + import inspect + + from virtual_ecosystem.models.animal.animal_model import AnimalModel + + # Comment: make failures descriptive and short to read in pytest output. + assert hasattr(animal_model_instance, "plant_resources"), ( + "plant_resources missing. " + f"_setup defined at: {inspect.getsourcefile(AnimalModel._setup)}:" + f"{inspect.getsourcelines(AnimalModel._setup)[1]} | " + f"attrs={sorted(vars(animal_model_instance))}" + ) + + # Optional: verify other _setup outputs exist; helps pinpoint where it failed. + for name in ( + "excrement_pools", + "carcass_pools", + "leaf_waste_pools", + "litter_pools", + ): + assert hasattr(animal_model_instance, name), f"Missing {name}" + # Run the forage_community method animal_model_instance.forage_community(dt=30) @@ -2098,6 +2120,10 @@ def test_forage_community( mock_forage_herbivore.assert_called_once_with( plant_list=["plant_resources"], animal_list=[], + fungal_fruit_list=[], + soil_fungi_list=[], + pom_list=[], + bacteria_list=[], litter_pools=[], excrement_pools=["excrement_pools_herbivore"], carcass_pool_map=animal_model_instance.carcass_pools, @@ -2113,6 +2139,10 @@ def test_forage_community( mock_forage_predator.assert_called_once_with( plant_list=[], animal_list=["prey"], + fungal_fruit_list=[], + soil_fungi_list=[], + pom_list=[], + bacteria_list=[], litter_pools=[], excrement_pools=["excrement_pools_predator"], carcass_pool_map=animal_model_instance.carcass_pools, diff --git a/tests/models/animals/test_animal_traits.py b/tests/models/animals/test_animal_traits.py index 0d6aae103..d4334bd31 100644 --- a/tests/models/animals/test_animal_traits.py +++ b/tests/models/animals/test_animal_traits.py @@ -37,7 +37,7 @@ def test_combined_flags(self): ("algae", "ALGAE"), ("wood", "WOOD"), ("invertebrates", "INVERTEBRATES"), - ("fungus", "FUNGUS"), + ("fungi", "FUNGI"), ("seeds", "SEEDS"), ("flowers", "FLOWERS"), ("nonfeeding", "NONFEEDING"), @@ -46,7 +46,7 @@ def test_combined_flags(self): ("foliage_fruit_blood", "FOLIAGE|FRUIT|BLOOD"), ("algae_detritus_wood", "ALGAE|DETRITUS|WOOD"), ("carcasses_blood_waste", "CARCASSES|BLOOD|WASTE"), - ("nectar_fungus_seeds", "NECTAR|FUNGUS|SEEDS"), + ("nectar_fungi_seeds", "NECTAR|FUNGI|SEEDS"), ("flowers_fruit_seeds", "FLOWERS|FRUIT|SEEDS"), ], ) @@ -88,11 +88,11 @@ def test_coarse_category(self): "flag_str, expected_count", [ ("FOLIAGE", 1), - ("FOLIAGE | FRUIT | FUNGUS", 3), + ("FOLIAGE | FRUIT | FUNGI", 3), ("NONFEEDING", 0), ( "HERBIVORE | CARCASSES | FOLIAGE", - 10, + 9, ), # includes 9 from HERBIVORE + CARCASSES ], ) diff --git a/tests/models/animals/test_functional_group.py b/tests/models/animals/test_functional_group.py index d81c87b4a..85df56740 100644 --- a/tests/models/animals/test_functional_group.py +++ b/tests/models/animals/test_functional_group.py @@ -372,7 +372,7 @@ def test_initialization( 12, "earthworm", "invertebrate", - "detritus", + "detritus_fungi_pom_bacteria", "ectothermic", "terrestrial", "iteroparous", diff --git a/tests/models/animals/test_scaling_functions.py b/tests/models/animals/test_scaling_functions.py index 17fdfbb41..5c69749eb 100644 --- a/tests/models/animals/test_scaling_functions.py +++ b/tests/models/animals/test_scaling_functions.py @@ -191,12 +191,29 @@ def test_carnivore_prey_group_selection(functional_group_list_instance): "detritivorous_insect": (0.0001, 1000.0), "dung_beetle": (0.0001, 1000.0), "scavenging_mammal": (0.0001, 1000.0), + "fungivorous_mammal": (0.0001, 1000.0), "carcasses": (0.0, 0.0), "excrement": (0.0, 0.0), } assert result == expected_output +def test_fungivore_prey_group_selection(functional_group_list_instance): + """Test for fungivore diet type selection.""" + from virtual_ecosystem.models.animal.scaling_functions import ( + DietType, + prey_group_selection, + ) + + result = prey_group_selection( + DietType.FUNGI, 10.0, (0.1, 1000.0), functional_group_list_instance + ) + expected = { + "fungi": (0.0, 0.0), + } + assert result == expected + + @pytest.mark.parametrize( "diet_flag, expected", [ @@ -211,12 +228,20 @@ def test_carnivore_prey_group_selection(functional_group_list_instance): # Herbivory + scavenging ( DietType.HERBIVORE | DietType.CARCASSES, - {"plants": (0.0, 0.0), "litter": (0.0, 0.0), "carcasses": (0.0, 0.0)}, + { + "plants": (0.0, 0.0), + "litter": (0.0, 0.0), + "carcasses": (0.0, 0.0), + }, ), # Herbivory + waste ( DietType.HERBIVORE | DietType.WASTE, - {"plants": (0.0, 0.0), "litter": (0.0, 0.0), "excrement": (0.0, 0.0)}, + { + "plants": (0.0, 0.0), + "litter": (0.0, 0.0), + "excrement": (0.0, 0.0), + }, ), # Detritivory only ( diff --git a/virtual_ecosystem/models/animal/animal_cohorts.py b/virtual_ecosystem/models/animal/animal_cohorts.py index ce92e8c70..0d1032d77 100644 --- a/virtual_ecosystem/models/animal/animal_cohorts.py +++ b/virtual_ecosystem/models/animal/animal_cohorts.py @@ -4,9 +4,9 @@ import random import uuid -from _collections_abc import Callable +from _collections_abc import Callable, Mapping from math import ceil, exp, sqrt -from typing import Literal +from typing import Literal, TypeVar, cast from numpy import timedelta64 @@ -19,12 +19,16 @@ from virtual_ecosystem.models.animal.decay import ( CarcassPool, ExcrementPool, + FungalFruitPool, HerbivoryWaste, + SoilPool, find_decay_consumed_split, ) from virtual_ecosystem.models.animal.functional_group import FunctionalGroup from virtual_ecosystem.models.animal.protocols import Resource +_T = TypeVar("_T") + class AnimalCohort: """This is a class of animal cohorts.""" @@ -699,32 +703,35 @@ def calculate_total_handling_time_for_herbivory( for plant in plant_list ) - def F_i_k(self, plant_list: list[Resource], target_plant: Resource) -> float: - """Method to determine instantaneous herbivory rate on plant k. + def F_i_k(self, resource_list: list[Resource], target_resource: Resource) -> float: + """Method to determine instantaneous consumption rate on resource k. This method integrates the calculated search efficiency, potential consumed biomass of the target plant, and the total handling time for all available - plant resources to determine the rate at which the target plant is consumed by + resources to determine the rate at which the target plant is consumed by the cohort. + This method is originally parameterized for herbivory but is currently used for + all non-predation consumer-resource interactions. + TODO: update name Args: - plant_list: A list of plant resources available for consumption by the + resource_list: A list of plant resources available for consumption by the cohort. - target_plant: The specific plant resource being targeted by the herbivore + target_resource: The specific resource being targeted by the herbivore cohort for consumption. Returns: - The instantaneous consumption rate [g/day] of the target plant resource by - the herbivore cohort. + The instantaneous consumption rate [g/day] of the target resource by + the consumer cohort. """ alpha = self.calculate_alpha() - k = self.calculate_potential_consumed_biomass(target_plant, alpha) + k = self.calculate_potential_consumed_biomass(target_resource, alpha) total_handling_t = self.calculate_total_handling_time_for_herbivory( - plant_list, alpha + resource_list, alpha ) - B_k = target_plant.mass_current # current plant biomass + B_k = target_resource.mass_current # current plant biomass N = self.individuals # herb cohort size return N * (k / (1 + total_handling_t)) * (1 / B_k) @@ -1070,10 +1077,102 @@ def delta_mass_excrement_scavenging( calculate_consumed_mass=self._consumed_resource_mass, ) + def delta_mass_fruiting_fungivory( + self, + fungal_fruit_list: list[Resource], + adjusted_dt: timedelta64, + herbivory_waste_pools: dict[int, HerbivoryWaste], + ) -> dict[str, float]: + """Handle mass assimilation from fruiting body (mushroom) fungivory. + + Args: + fungal_fruit_list: List of fungal fruiting resources. + adjusted_dt: Time available for foraging. + herbivory_waste_pools: Waste pools for unassimilated fungal matter. + + Returns: + Stoichiometric mass gained by the cohort. + """ + return self.forage_resource_list( + resources=fungal_fruit_list, + adjusted_dt=adjusted_dt, + calculate_consumed_mass=self._consumed_resource_mass, + herbivory_waste_pools=herbivory_waste_pools, + ) + + def delta_mass_soil_fungivory( + self, + soil_fungi_list: list[Resource], + adjusted_dt: timedelta64, + ) -> dict[str, float]: + """Handle mass assimilation from soil fungi foraging. + + Args: + soil_fungi_list: List of soil fungi resources (distinct from fruiting + bodies). + adjusted_dt: Time available for foraging. + + Returns: + Stoichiometric mass gained by the cohort. + """ + + return self.forage_resource_list( + resources=soil_fungi_list, + adjusted_dt=adjusted_dt, + calculate_consumed_mass=self._consumed_resource_mass, + herbivory_waste_pools=None, + ) + + def delta_mass_pomivory( + self, + pom_list: list[Resource], + adjusted_dt: timedelta64, + ) -> dict[str, float]: + """Handle mass assimilation from POM (particulate organic matter) foraging. + + Args: + pom_list: List of particulate organic matter soil resources. + adjusted_dt: Time available for foraging. + + Returns: + Stoichiometric mass gained by the cohort. + """ + return self.forage_resource_list( + resources=pom_list, + adjusted_dt=adjusted_dt, + calculate_consumed_mass=self._consumed_resource_mass, + herbivory_waste_pools=None, + ) + + def delta_mass_bacteriophagy( + self, + bacteria_list: list[Resource], + adjusted_dt: timedelta64, + ) -> dict[str, float]: + """Handle mass assimilation from soil bacteria. + + Args: + bacteria_list: List of soil bacteria resources. + adjusted_dt: Time available for foraging. + + Returns: + Stoichiometric mass gained by the cohort. + """ + return self.forage_resource_list( + resources=bacteria_list, + adjusted_dt=adjusted_dt, + calculate_consumed_mass=self._consumed_resource_mass, + herbivory_waste_pools=None, + ) + def forage_cohort( self, plant_list: list[Resource], animal_list: list[AnimalCohort], + fungal_fruit_list: list[Resource], + soil_fungi_list: list[Resource], + pom_list: list[Resource], + bacteria_list: list[Resource], litter_pools: list[Resource], excrement_pools: list[ExcrementPool], carcass_pool_map: dict[int, list[CarcassPool]], @@ -1094,6 +1193,10 @@ def forage_cohort( Args: plant_list: Live plant resources available for herbivory. animal_list: Live prey cohorts available for predation. + fungal_fruit_list: Live fungal fruiting bodies available for consumption. + soil_fungi_list: Soil fungi pools (not fruiting bodies). + pom_list: Soil particulate organic matter pools (POM). + bacteria_list: Soil bacteria pools. litter_pools: LitterPool objects available for detritivory. excrement_pools: ExcrementPool objects used for defecation deposition. @@ -1147,6 +1250,43 @@ def forage_cohort( for k in total_gain: total_gain[k] += gain[k] + # live mushroom fungivory + if fungal_fruit_list: + gain = self.delta_mass_fruiting_fungivory( + fungal_fruit_list=fungal_fruit_list, + adjusted_dt=time_available_per_diet, + herbivory_waste_pools=herbivory_waste_pools, + ) + for k in total_gain: + total_gain[k] += gain[k] + + # soil fungi fungivory + if soil_fungi_list: + gain = self.delta_mass_soil_fungivory( + soil_fungi_list=soil_fungi_list, + adjusted_dt=time_available_per_diet, + ) + for k in total_gain: + total_gain[k] += gain[k] + + # particulate organic matter consumption + if pom_list: + gain = self.delta_mass_pomivory( + pom_list=pom_list, + adjusted_dt=time_available_per_diet, + ) + for k in total_gain: + total_gain[k] += gain[k] + + # bacteria foraging + if bacteria_list: + gain = self.delta_mass_bacteriophagy( + bacteria_list=bacteria_list, + adjusted_dt=time_available_per_diet, + ) + for k in total_gain: + total_gain[k] += gain[k] + # litter detritivory if litter_pools: gain = self.delta_mass_detritivory( @@ -1421,103 +1561,168 @@ def can_forage_on(self, resource: Resource) -> bool: """ return self.match_vertical(resource.vertical_occupancy) + def _get_resources_in_territory( + self, + resource_map: Mapping[int, _T | list[_T]], + filter_fn: Callable[[_T], bool] | None = None, + ) -> list[_T]: + """Return resources from territory; accepts singleton or list per cell. + + This normalizes each per-cell entry to a list, applies an optional filter, + and flattens the result. + + Args: + resource_map: Mapping from cell_id to a single resource or a list. + filter_fn: Optional predicate to retain resources (True keeps item). + + Returns: + A flat list of resources located within the cohort's territory. + """ + # Collect results from all territory cells + result: list[_T] = [] + + for cell_id in self.territory: + entry = resource_map.get(cell_id) + if entry is None: + continue + + # Normalize to a list + items = entry if isinstance(entry, list) else [entry] + + # Apply optional filter + if filter_fn is not None: + items = [r for r in items if filter_fn(r)] + + result.extend(items) + + return result + def get_plant_resources( self, plant_resources: dict[int, list[Resource]] ) -> list[Resource]: """Return plant resources accessible within this cohort's territory. + This method filters the plant resources by territory and the cohort's + foraging capability (via `can_forage_on`). + Args: - plant_resources: Dictionary of plant resources keyed by grid cell IDs. + plant_resources: A dictionary mapping cell IDs to lists of plant + resource objects. Returns: - List of accessible Resource objects within the territory. + A list of plant Resource objects that the cohort can forage on. """ - plant_resources_in_territory: list[Resource] = [] - - # Iterate over all grid cell keys in this territory - for cell_id in self.territory: - # Check if the cell_id is within the provided plant resources - if cell_id in plant_resources: - for resource in plant_resources[cell_id]: - if self.can_forage_on(resource): - plant_resources_in_territory.append(resource) - - return plant_resources_in_territory + return self._get_resources_in_territory(plant_resources, self.can_forage_on) def get_excrement_pools( self, excrement_pools: dict[int, list[ExcrementPool]] ) -> list[ExcrementPool]: - """Returns a list of excrement pools in this territory. + """Return excrement pools within the cohort's territory. - This method checks which grid cells are within this territory - and returns a list of the excrement pools available in those grid cells. + This method returns all ExcrementPool objects that are located in grid + cells occupied by the cohort. Args: - excrement_pools: A dictionary of excrement pools where keys are grid - cell IDs. + excrement_pools: A dictionary mapping cell IDs to lists of ExcrementPool + objects. Returns: - A list of ExcrementPool objects in this territory. + A list of ExcrementPool objects in the cohort's territory. """ - excrement_pools_in_territory: list[ExcrementPool] = [] + return self._get_resources_in_territory(excrement_pools) - # 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]) + def get_carcass_pools( + self, carcass_pools: dict[int, list[CarcassPool]] + ) -> list[CarcassPool]: + """Return carcass pools within the cohort's territory. - return excrement_pools_in_territory + This method returns all CarcassPool objects located in grid cells + that the cohort occupies. - def get_herbivory_waste_pools( - self, plant_waste: dict[int, HerbivoryWaste] - ) -> list[HerbivoryWaste]: - """Returns a list of herbivory waste pools in this territory. + Args: + carcass_pools: A dictionary mapping cell IDs to lists of CarcassPool + objects. - This method checks which grid cells are within this territory - and returns a list of the herbivory waste pools available in those grid cells. + Returns: + A list of CarcassPool objects in the cohort's territory. + """ + return self._get_resources_in_territory(carcass_pools) + + def get_fungal_fruit_pools( + self, fungal_fruiting_bodies: dict[int, FungalFruitPool] + ) -> list[Resource]: + """Return fungal fruiting-body pools within the cohort's territory. Args: - plant_waste: A dictionary of herbivory waste pools where keys are grid - cell IDs. + fungal_fruiting_bodies: The fungal fruiting pools the model. Returns: - A list of HerbivoryWaste objects in this territory. + A list of fungal fruiting-body Resource objects available in + the cohort's territory. """ - plant_waste_pools_in_territory: list[HerbivoryWaste] = [] - # Iterate over all grid cell keys in this territory - for cell_id in self.territory: - # Check if the cell_id is within the provided herbivory waste pools - if cell_id in plant_waste: - plant_waste_pools_in_territory.append(plant_waste[cell_id]) + fungal_fruits = self._get_resources_in_territory( + fungal_fruiting_bodies, self.can_forage_on + ) + return cast(list[Resource], fungal_fruits) - return plant_waste_pools_in_territory + def get_soil_fungi_pools( + self, soil_pools: dict[int, dict[str, SoilPool]] + ) -> list[Resource]: + """Return soil fungi pools within the cohort's territory. - def get_carcass_pools( - self, carcass_pools: dict[int, list[CarcassPool]] - ) -> list[CarcassPool]: - """Returns a list of carcass pools in this territory. + Args: + soil_pools: Mapping from cell_id to SoilPool objects keyed by 'fungi', + 'pom', and 'bacteria'. - This method checks which grid cells are within this territory - and returns a list of the carcass pools available in those grid cells. + Returns: + List of soil-fungi Resource objects within the territory. + """ + fungi_by_cell: dict[int, SoilPool] = { + cid: pools["fungi"] for cid, pools in soil_pools.items() if "fungi" in pools + } + pools_list = self._get_resources_in_territory(fungi_by_cell, self.can_forage_on) + return cast(list[Resource], pools_list) + + def get_pom_pools( + self, soil_pools: dict[int, dict[str, SoilPool]] + ) -> list[Resource]: + """Return soil POM pools within the cohort's territory. Args: - carcass_pools: A dictionary of carcass pools where keys are grid - cell IDs. + soil_pools: Mapping from cell_id to SoilPool objects keyed by 'fungi', + 'pom', and 'bacteria'. Returns: - A list of CarcassPool objects in this territory. + List of POM Resource objects within the territory. """ - carcass_pools_in_territory: list[CarcassPool] = [] + pom_by_cell: dict[int, SoilPool] = { + cid: pools["pom"] for cid, pools in soil_pools.items() if "pom" in pools + } + pools_list = self._get_resources_in_territory(pom_by_cell, self.can_forage_on) + return cast(list[Resource], pools_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 carcass pools - if cell_id in carcass_pools: - carcass_pools_in_territory.extend(carcass_pools[cell_id]) + def get_bacteria_pools( + self, soil_pools: dict[int, dict[str, SoilPool]] + ) -> list[Resource]: + """Return soil bacteria pools within the cohort's territory. - return carcass_pools_in_territory + Args: + soil_pools: Mapping from cell_id to SoilPool objects keyed by 'fungi', + 'pom', and 'bacteria'. + + Returns: + List of bacterial Resource objects within the territory. + """ + bacteria_by_cell: dict[int, SoilPool] = { + cid: pools["bacteria"] + for cid, pools in soil_pools.items() + if "bacteria" in pools + } + pools_list = self._get_resources_in_territory( + bacteria_by_cell, self.can_forage_on + ) + return cast(list[Resource], pools_list) def find_intersecting_carcass_pools( self, @@ -1539,6 +1744,31 @@ def find_intersecting_carcass_pools( intersecting_carcass_pools.extend(carcass_pools[cell_id]) return intersecting_carcass_pools + def get_herbivory_waste_pools( + self, plant_waste: dict[int, HerbivoryWaste] + ) -> list[HerbivoryWaste]: + """Returns a list of herbivory waste pools in this territory. + + This method checks which grid cells are within this territory + and returns a list of the herbivory waste pools available in those grid cells. + + Args: + plant_waste: A dictionary of herbivory waste pools where keys are grid + cell IDs. + + Returns: + A list of HerbivoryWaste objects in this territory. + """ + plant_waste_pools_in_territory: list[HerbivoryWaste] = [] + + # Iterate over all grid cell keys in this territory + for cell_id in self.territory: + # Check if the cell_id is within the provided herbivory waste pools + if cell_id in plant_waste: + plant_waste_pools_in_territory.append(plant_waste[cell_id]) + + return plant_waste_pools_in_territory + def is_migration_season(self) -> bool: """Handles determination of whether it is time to migrate. diff --git a/virtual_ecosystem/models/animal/animal_model.py b/virtual_ecosystem/models/animal/animal_model.py index 8e34810e6..75666c3fd 100644 --- a/virtual_ecosystem/models/animal/animal_model.py +++ b/virtual_ecosystem/models/animal/animal_model.py @@ -418,6 +418,7 @@ def _setup( ] for cell_id in self.data.grid.cell_id } + # 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 @@ -1418,22 +1419,30 @@ def birth_community(self) -> None: self.birth(cohort) def forage_community(self, dt: timedelta64) -> None: - """Loop through every active cohort and trigger resource consumption. - - The diet flags on each cohort determine which resource lists are - assembled and forwarded to ``cohort.forage_cohort``: - - * ``DietType.HERBIVORE`` → live plant resources - * ``DietType.CARNIVORE`` → live prey cohorts - * ``DietType.DETRITUS`` → plant-litter pools (detritivory) - * ``DietType.CARCASSES`` → carcass pools (scavenging) - * ``DietType.WASTE`` → excrement pools (coprophagy) + """Loop through each active cohort and trigger resource consumption. + + Diet flags on a cohort determine which resource lists are assembled and + forwarded to ``cohort.forage_cohort``: + + * ``DietType.HERBIVORE`` → live plant resources + * ``DietType.CARNIVORE`` → live prey cohorts + * ``DietType.DETRITUS`` → plant-litter pools (detritivory) + * ``DietType.CARCASSES`` → carcass pools (scavenging) + * ``DietType.WASTE`` → excrement pools (coprophagy) + * ``DietType.MUSHROOMS`` → fungal fruiting bodies + * ``DietType.FUNGI`` → soil fungi (SoilPool['fungi']) + * ``DietType.POM`` → soil POM (SoilPool['pom']) + * ``DietType.BACTERIA`` → soil bacteria (SoilPool['bacteria']) + + Deposition targets (``excrement_pools`` for faeces and ``carcass_pool_map`` + for uneaten prey remains) are always supplied so trophic functions can + update them regardless of whether the cohort actively scavenges in the + same step. - Deposition targets (``excrement_pools`` for faeces and - ``carcass_pool_map`` for uneaten prey remains) are always supplied so - trophic functions can update them regardless of whether the cohort - actively scavenges in the same step. + Args: + dt: Time step duration. """ + for cohort in list(self.active_cohorts.values()): # Safety check territory must be defined if cohort.territory is None: @@ -1441,9 +1450,13 @@ def forage_community(self, dt: timedelta64) -> None: diet: DietType = cohort.functional_group.diet - # Build resource collections based on diet flags + # Build resource collections based on diet flags plant_list: list[Resource] = [] prey_list: list[AnimalCohort] = [] + fungal_fruit_list: list[Resource] = [] + soil_fungi_list: list[Resource] = [] + pom_list: list[Resource] = [] + bacteria_list: list[Resource] = [] litter_list: list[Resource] = [] scavenge_carcass_pools: list[Resource] = [] scavenge_waste_pools: list[Resource] = [] @@ -1458,7 +1471,6 @@ def forage_community(self, dt: timedelta64) -> None: | DietType.FLOWERS | DietType.FOLIAGE | DietType.FRUIT - | DietType.FUNGUS | DietType.SEEDS | DietType.NECTAR | DietType.WOOD @@ -1474,7 +1486,25 @@ def forage_community(self, dt: timedelta64) -> None: ): prey_list = cohort.get_prey(self.communities) - # Detritivory + # Fruiting-body fungivory + if diet & DietType.MUSHROOMS: + fungal_fruit_list = cohort.get_fungal_fruit_pools( + self.fungal_fruiting_bodies + ) + + # Soil fungi + if diet & DietType.FUNGI: + soil_fungi_list = cohort.get_soil_fungi_pools(self.soil_pools) + + # Soil POM + if diet & DietType.POM: + pom_list = cohort.get_pom_pools(self.soil_pools) + + # Soil bacteria + if diet & DietType.BACTERIA: + bacteria_list = cohort.get_bacteria_pools(self.soil_pools) + + # Plant litter detritivory if diet & DietType.DETRITUS: litter_list = cohort.get_litter_pools(self.litter_pools) @@ -1488,9 +1518,14 @@ def forage_community(self, dt: timedelta64) -> None: if diet & DietType.WASTE: scavenge_waste_pools = cast(list[Resource], excrement_pools) + # Delegate to cohort-level foraging cohort.forage_cohort( plant_list=plant_list, animal_list=prey_list, + fungal_fruit_list=fungal_fruit_list, + soil_fungi_list=soil_fungi_list, + pom_list=pom_list, + bacteria_list=bacteria_list, litter_pools=litter_list, excrement_pools=excrement_pools, # for defecation carcass_pool_map=carcass_pool_map, # for prey remains @@ -1500,7 +1535,7 @@ def forage_community(self, dt: timedelta64) -> None: dt=dt, ) - # Remove any cohorts that died during foraging + # Remove cohorts that died during foraging self.remove_dead_cohort_community() def metabolize_community(self, dt: timedelta64) -> None: diff --git a/virtual_ecosystem/models/animal/animal_traits.py b/virtual_ecosystem/models/animal/animal_traits.py index f1b2edb34..f5384952e 100644 --- a/virtual_ecosystem/models/animal/animal_traits.py +++ b/virtual_ecosystem/models/animal/animal_traits.py @@ -14,14 +14,19 @@ class MetabolicType(Enum): class DietType(Flag): - """Enumeration for diet resource types.""" + """Enumeration for diet resource types. + + TODO: refine categorizations + + """ ALGAE = auto() DETRITUS = auto() FLOWERS = auto() FOLIAGE = auto() FRUIT = auto() - FUNGUS = auto() + MUSHROOMS = auto() + FUNGI = auto() SEEDS = auto() BLOOD = auto() INVERTEBRATES = auto() @@ -32,6 +37,8 @@ class DietType(Flag): WASTE = auto() WOOD = auto() NONFEEDING = auto() + POM = auto() + BACTERIA = auto() HERBIVORE = ( ALGAE @@ -39,7 +46,6 @@ class DietType(Flag): | FLOWERS | FOLIAGE | FRUIT - | FUNGUS | SEEDS | NECTAR | WOOD diff --git a/virtual_ecosystem/models/animal/decay.py b/virtual_ecosystem/models/animal/decay.py index d5463a2bd..ce30fc98b 100644 --- a/virtual_ecosystem/models/animal/decay.py +++ b/virtual_ecosystem/models/animal/decay.py @@ -295,6 +295,9 @@ def __init__( f"({self.mass_cnp})." ) + vertical_occupancy: VerticalOccupancy = VerticalOccupancy.GROUND + """Vertical position of fungal fruiting pool.""" + @property def mass_current(self) -> float: """Return current carbon mass in the pool [kg].""" @@ -468,7 +471,7 @@ class SoilPool: cell***. """ - vertical_occupancy: VerticalOccupancy = VerticalOccupancy.GROUND + vertical_occupancy: VerticalOccupancy = VerticalOccupancy.SOIL """Vertical position of soil pool.""" def __init__( diff --git a/virtual_ecosystem/models/animal/scaling_functions.py b/virtual_ecosystem/models/animal/scaling_functions.py index 48fcb3af3..0b193d3d0 100644 --- a/virtual_ecosystem/models/animal/scaling_functions.py +++ b/virtual_ecosystem/models/animal/scaling_functions.py @@ -177,6 +177,16 @@ def prey_group_selection( result["excrement"] = (0.0, 0.0) if diet_type & DietType.DETRITUS: result["litter"] = (0.0, 0.0) + if diet_type & DietType.MUSHROOMS: + # mushroom pool + result["fungal_fruiting_bodies"] = (0.0, 0.0) + if diet_type & DietType.FUNGI: + # Soil fungi pool + result["fungi"] = (0.0, 0.0) + if diet_type & DietType.POM: + result["pom"] = (0.0, 0.0) + if diet_type & DietType.BACTERIA: + result["bacteria"] = (0.0, 0.0) if not result: raise ValueError(f"No prey groups matched for diet type: {diet_type}")