Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/juvenile dispersal - Adding juvenile dispersal to the Animal Module #419

Merged
merged 16 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions tests/models/animals/test_animal_cohorts.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,12 +965,53 @@ def test_forage_cohort(
)
mock_eat_predator.assert_called_once_with(200)

# Test for error on inappropriate food source
with pytest.raises(ValueError):
herbivore_cohort_instance.forage_cohort(
[], [], excrement_pool_instance, carcass_pool_instance
)
with pytest.raises(ValueError):
predator_cohort_instance.forage_cohort(
[], [], excrement_pool_instance, carcass_pool_instance
)
@pytest.mark.parametrize(
"mass_current, V_disp, M_disp_ref, o_disp, expected_probability",
[
pytest.param(10, 0.5, 10, 0.5, 0.5, id="normal_case"),
pytest.param(10, 1.5, 10, 0.5, 1.0, id="cap_at_1"),
pytest.param(10, 0, 10, 0.5, 0, id="zero_velocity"),
pytest.param(0, 0.5, 10, 0.5, 0, id="zero_mass"),
],
)
def test_migrate_juvenile_probability(
self,
mocker,
mass_current,
V_disp,
M_disp_ref,
o_disp,
expected_probability,
herbivore_cohort_instance,
):
"""Test the calculation of juvenile migration probability."""
from math import sqrt

# Assign test-specific values to the cohort instance
cohort = herbivore_cohort_instance
cohort.mass_current = mass_current
cohort.constants = mocker.MagicMock(
V_disp=V_disp, M_disp_ref=M_disp_ref, o_disp=o_disp
)

# Mock juvenile_dispersal_speed
mocked_velocity = V_disp * (mass_current / M_disp_ref) ** o_disp
mocker.patch(
"virtual_ecosystem.models.animals.scaling_functions."
"juvenile_dispersal_speed",
return_value=mocked_velocity,
)

# Calculate expected probability
A_cell = 1.0
grid_side = sqrt(A_cell)
calculated_probability = mocked_velocity / grid_side
expected_probability = min(calculated_probability, 1.0) # Cap at 1.0

# Call the method under test
probability_of_dispersal = cohort.migrate_juvenile_probability()

# Assertion to check if the method returns the correct probability
assert (
probability_of_dispersal == expected_probability
), "The probability calculated did not match the expected probability."
93 changes: 72 additions & 21 deletions tests/models/animals/test_animal_communities.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,45 +115,96 @@ def test_migrate(
]
)

@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,
mocker,
mass_ratio,
age,
probability_output,
should_migrate,
):
"""Test migration of cohorts below the mass threshold."""
"""Test migration of cohorts for both starving and juvenile conditions."""

# Mock the get_destination callable in this specific test context.
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,
)

# Create a low mass cohort and append it to the source community.
low_mass_cohort = animal_cohort_instance
low_mass_cohort.mass_current = low_mass_cohort.functional_group.adult_mass / 2
animal_community_instance.animal_cohorts["herbivorous_mammal"].append(
low_mass_cohort
# 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 that the cohort has been removed from the source community
assert (
low_mass_cohort
not in animal_community_instance.animal_cohorts["herbivorous_mammal"]
)

# Check that the cohort has been added to the destination community
assert (
low_mass_cohort
in animal_community_destination_instance.animal_cohorts[
"herbivorous_mammal"
]
)
# 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
Expand Down
28 changes: 28 additions & 0 deletions tests/models/animals/test_scaling_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,31 @@ def test_H_i_j(h_pred_0, M_ref, M_i_t, b_pred, expected_handling_time):
assert calculated_handling_time == pytest.approx(
expected_handling_time, rel=1e-6
)


@pytest.mark.parametrize(
"current_mass, V_disp, M_disp_ref, o_disp, expected_speed",
[
pytest.param(1.0, 10.0, 1.0, 1.0, 10.0, id="reference_mass"),
pytest.param(0.5, 10.0, 1.0, 1.0, 5.0, id="half_reference_mass"),
pytest.param(2.0, 10.0, 1.0, 1.0, 20.0, id="double_reference_mass"),
pytest.param(1.0, 20.0, 1.0, 1.0, 20.0, id="double_speed"),
pytest.param(1.0, 10.0, 1.0, 0.5, 10.0, id="sqrt_scaling"),
pytest.param(
4.0, 10.0, 2.0, 0.5, 14.142135, id="sqrt_scaling_with_different_ref"
),
pytest.param(0.0, 10.0, 1.0, 1.0, 0.0, id="zero_mass"),
],
)
def test_juvenile_dispersal_speed(
current_mass, V_disp, M_disp_ref, o_disp, expected_speed
):
"""Testing the juvenile dispersal speed calculation for various scenarios."""
from virtual_ecosystem.models.animals.scaling_functions import (
juvenile_dispersal_speed,
)

calculated_speed = juvenile_dispersal_speed(
current_mass, V_disp, M_disp_ref, o_disp
)
assert calculated_speed == pytest.approx(expected_speed, rel=1e-6)
53 changes: 45 additions & 8 deletions virtual_ecosystem/models/animals/animal_cohorts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from __future__ import annotations

from collections.abc import Sequence
from math import ceil, exp
from math import ceil, exp, sqrt

from numpy import random, timedelta64

Expand Down Expand Up @@ -609,12 +609,6 @@ def forage_cohort(
# Update the predator's mass with the total gained mass
self.eat(consumed_mass)

else:
# No appropriate food sources for the diet type
raise ValueError(
f"No appropriate foods available for {self.functional_group.diet} diet."
)

def theta_i_j(self, animal_list: Sequence[AnimalCohort]) -> float:
"""Cumulative density method for delta_mass_predation.

Expand Down Expand Up @@ -687,7 +681,7 @@ def is_below_mass_threshold(self, mass_threshold: float) -> bool:
def inflict_natural_mortality(
self, carcass_pool: CarcassPool, number_days: float
) -> None:
"""The function to cause natural mortality in a cohort.
"""Inflicts natural mortality in a cohort.

TODO Find a more efficient structure so we aren't recalculating the
time_step_mortality. Probably pass through the initialized timestep size to the
Expand All @@ -711,3 +705,46 @@ def inflict_natural_mortality(
)

self.die_individual(number_of_deaths, carcass_pool)

def migrate_juvenile_probability(self) -> float:
TaranRallings marked this conversation as resolved.
Show resolved Hide resolved
"""The probability that a juvenile cohort will migrate to a new grid cell.

TODO: This does not hold for diagonal moves or non-square grids.

Following Madingley's assumption that the probability of juvenile dispersal is
equal to the proportion of the cohort individuals that would arrive in the
neighboring cell after one full timestep's movement.

Assuming cohort individuals are homogenously distributed within a grid cell and
that the move is non-diagonal, the probability is then equal to the ratio of
dispersal speed to the side-length of a grid cell.

A homogenously distributed cohort with a partial presence in a grid cell will
have a proportion of its individuals in the new grid cell equal to the
proportion the new grid cell that it occupies (A_new / A_cell). This proportion
will be equal to the cohorts velocity (V) multiplied by the elapsed time (t)
multiplied by the length of one side of a grid cell (L) (V*t*L) (t is assumed
to be 1 here). The area of the square grid cell is the square of the length of
one side. The proportion of individuals in the new cell is then:
A_new / A_cell = (V * T * L) / (L * L) = ((L/T) * T * L) / (L * L ) =
dimensionless
[m2 / m2 = (m/d * d * m) / (m * m) = m / m = dimensionless]

Returns:
The probability of diffusive natal dispersal to a neighboring grid cell.

"""

A_cell = 1.0 # TODO: update this to actual grid reference
grid_side = sqrt(A_cell)
velocity = sf.juvenile_dispersal_speed(
self.mass_current,
self.constants.V_disp,
self.constants.M_disp_ref,
self.constants.o_disp,
)

# not a true probability as can be > 1, reduced to 1.0 in return statement
probability_of_dispersal = velocity / grid_side
TaranRallings marked this conversation as resolved.
Show resolved Hide resolved

return min(1.0, probability_of_dispersal)
53 changes: 34 additions & 19 deletions virtual_ecosystem/models/animals/animal_communities.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

from __future__ import annotations

import random
from collections.abc import Callable, Iterable
from itertools import chain
from random import choice

from numpy import timedelta64

Expand Down Expand Up @@ -116,10 +116,11 @@ def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None:
This function should take a cohort and a destination community and then pop the
cohort from this community to the destination.

Travel distance is not currently a function of body-size or locomotion.
Travel distance is not currently a function of body-size or locomotion for
starvation dispersal.

TODO: Implement juvenile dispersal.
TODO: Implement low-density trigger.
TODO: Implement low-density trigger. [might not actually do this, requires
cohort merging.]

Args:
migrant: The AnimalCohort moving between AnimalCommunities.
Expand All @@ -131,13 +132,27 @@ def migrate(self, migrant: AnimalCohort, destination: AnimalCommunity) -> None:
destination.animal_cohorts[migrant.name].append(migrant)

def migrate_community(self) -> None:
"""This handles migrating all cohorts in a community."""
"""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:
if cohort.is_below_mass_threshold(self.constants.dispersal_mass_threshold):
# Random walk destination from the neighbouring keys
destination_key = choice(self.neighbouring_keys)
destination = self.get_destination(destination_key)
self.migrate(cohort, destination)
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.
Expand Down Expand Up @@ -184,17 +199,17 @@ def birth(self, parent_cohort: AnimalCohort) -> None:
# reduce reproductive mass by amount used to generate offspring
parent_cohort.reproductive_mass = 0.0

# add a new cohort of the parental type to the community
self.animal_cohorts[parent_cohort.name].append(
AnimalCohort(
parent_cohort.functional_group,
parent_cohort.functional_group.birth_mass,
0.0,
number_offspring,
self.constants,
)
offspring_cohort = AnimalCohort(
parent_cohort.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)

def birth_community(self) -> None:
"""This handles birth for all cohorts in a community."""

Expand Down
Loading
Loading