diff --git a/README.md b/README.md index 204cda5a20..4ca366d044 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Flake8](https://img.shields.io/badge/Flake8-passed-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) [![Pytest](https://img.shields.io/badge/Pytest-passed-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) [![Coverage](https://img.shields.io/badge/Coverage-99%25-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) -[![Mypy](https://img.shields.io/badge/Mypy-1195%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Mypy](https://img.shields.io/badge/Mypy-1194%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems diff --git a/RUFAS/biophysical/animal/animal.py b/RUFAS/biophysical/animal/animal.py index baf7baeb6a..95e438af37 100644 --- a/RUFAS/biophysical/animal/animal.py +++ b/RUFAS/biophysical/animal/animal.py @@ -1748,14 +1748,6 @@ def animal_life_stage_update(self, time: RufasTime) -> tuple[AnimalStatus, NewBo self.cull_reason = animal_constants.DEATH_CULL animal_status = AnimalStatus.DEAD - if ( - self.animal_type.is_cow - and self.reproduction.do_not_breed - and self.milk_production.daily_milk_produced < AnimalConfig.cull_milk_production - ): - self.cull_reason = animal_constants.LOW_PROD_CULL - self.sold_at_day = time.simulation_day - animal_status = AnimalStatus.SOLD return animal_status, newborn_calf_config def _evaluate_calf_for_heiferI(self) -> bool: diff --git a/RUFAS/biophysical/animal/animal_config.py b/RUFAS/biophysical/animal/animal_config.py index 4f73adecdf..ac749df44b 100644 --- a/RUFAS/biophysical/animal/animal_config.py +++ b/RUFAS/biophysical/animal/animal_config.py @@ -37,8 +37,6 @@ class AnimalConfig: Maximum day at which a heifer is culled if not pregnant, (simulation days). do_not_breed_time : int The duration after which breeding is stopped, (simulation days). - cull_milk_production : int - The threshold milk production below which cows are culled, (simulation days). semen_type : str Types of semen used for reproduction, e.g., "conventional", (unitless). male_calf_rate_conventional_semen : float @@ -193,7 +191,6 @@ class AnimalConfig: dry_off_day_of_pregnancy: int = 218 heifer_reproduction_cull_day: int = 500 do_not_breed_time: int = 185 - cull_milk_production: int = 30 semen_type: str = "conventional" male_calf_rate_conventional_semen: float = 0.53 @@ -392,7 +389,6 @@ def initialize_animal_config(cls) -> None: cls.dry_off_day_of_pregnancy = animal_config_data["management_decisions"]["days_in_preg_when_dry"] cls.heifer_reproduction_cull_day = animal_config_data["management_decisions"]["heifer_repro_cull_time"] cls.do_not_breed_time = animal_config_data["management_decisions"]["do_not_breed_time"] - cls.cull_milk_production = animal_config_data["management_decisions"]["cull_milk_production"] cls.semen_type = animal_config_data["management_decisions"]["semen_type"] cls.male_calf_rate_conventional_semen = animal_config_data["farm_level"]["calf"][ diff --git a/RUFAS/biophysical/animal/animal_constants.py b/RUFAS/biophysical/animal/animal_constants.py index cef364e522..b4c9c254cb 100644 --- a/RUFAS/biophysical/animal/animal_constants.py +++ b/RUFAS/biophysical/animal/animal_constants.py @@ -44,6 +44,12 @@ # heifer repro INJECT_CIDR = "inject CIDR" +# herd size management +MIN_DIM_FOR_REMOVAL = 60 +"""Minimum days in milk required for a cow to be eligible for removal.""" +MAX_DAYS_IN_PREG_FOR_REMOVAL = 180 +"""Maximum pregnancy duration for a cow to be eligible for removal.""" + # presynch protocols PRESYNCH_PERIOD_START = "Presynch period started" PRESYNCH_PERIOD_END = "Presynch period ended" @@ -86,7 +92,7 @@ # culling HEIFER_REPRO_CULL = "culled for heifer reproductive problem" -LOW_PROD_CULL = "culled for low production" +OVERSUPPLY_CULL = "culled for herd resize" DEATH_CULL = "culled for death" LAMENESS_CULL = "culled for lameness" INJURY_CULL = "culled for injury" diff --git a/RUFAS/biophysical/animal/animal_module_reporter.py b/RUFAS/biophysical/animal/animal_module_reporter.py index fe557b2e24..ae34c85a71 100644 --- a/RUFAS/biophysical/animal/animal_module_reporter.py +++ b/RUFAS/biophysical/animal/animal_module_reporter.py @@ -596,8 +596,8 @@ def report_herd_statistics_data(cls, herd_statistics: HerdStatistics, simulation "data_origin": [("HerdManager", "daily_update")], } om.add_variable( - "sold_heiferIII_oversupply_num", - herd_statistics.sold_heiferIII_oversupply_num, + "sold_cow_oversupply_num", + herd_statistics.sold_cow_oversupply_num, dict(info_map, **{"units": MeasurementUnits.ANIMALS}), ) om.add_variable( @@ -841,7 +841,7 @@ def report_herd_statistics_data(cls, herd_statistics: HerdStatistics, simulation ) cull_reason_stats_units = { animal_constants.DEATH_CULL: MeasurementUnits.UNITLESS, - animal_constants.LOW_PROD_CULL: MeasurementUnits.UNITLESS, + animal_constants.OVERSUPPLY_CULL: MeasurementUnits.UNITLESS, animal_constants.LAMENESS_CULL: MeasurementUnits.UNITLESS, animal_constants.INJURY_CULL: MeasurementUnits.UNITLESS, animal_constants.MASTITIS_CULL: MeasurementUnits.UNITLESS, diff --git a/RUFAS/biophysical/animal/data_types/herd_statistics.py b/RUFAS/biophysical/animal/data_types/herd_statistics.py index 44475ededb..ef202b6209 100644 --- a/RUFAS/biophysical/animal/data_types/herd_statistics.py +++ b/RUFAS/biophysical/animal/data_types/herd_statistics.py @@ -47,8 +47,8 @@ class HerdStatistics: Number of stillborn calves during a specific period, (unitless). sold_calf_num : int Number of calves sold during a specific period, (unitless). - sold_heiferIII_oversupply_num : int - Number of surplus "Heifer III" animals sold, (unitless). + sold_cow_oversupply_num : int + Number of surplus cow sold, (unitless). bought_heifer_num : int Number of heifers purchased during a specific period, (unitless). sold_heiferII_num : int @@ -178,7 +178,7 @@ class HerdStatistics: stillborn_calf_num = 0 sold_calf_num = 0 - sold_heiferIII_oversupply_num = 0 + sold_cow_oversupply_num = 0 bought_heifer_num = 0 sold_heiferII_num = 0 cow_herd_exit_num = 0 @@ -256,7 +256,7 @@ def __init__(self) -> None: } self.cull_reason_stats = { animal_constants.DEATH_CULL: 0, - animal_constants.LOW_PROD_CULL: 0, + animal_constants.OVERSUPPLY_CULL: 0, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -270,7 +270,7 @@ def __init__(self) -> None: self.avg_age_for_parity = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "greater_than_5": 0} self.cull_reason_stats_percent = { animal_constants.DEATH_CULL: 0.0, - animal_constants.LOW_PROD_CULL: 0.0, + animal_constants.OVERSUPPLY_CULL: 0.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -313,7 +313,7 @@ def reset_daily_stats(self) -> None: self.stillborn_calf_num = 0 self.sold_calf_num = 0 - self.sold_heiferIII_oversupply_num = 0 + self.sold_cow_oversupply_num = 0 self.bought_heifer_num = 0 self.sold_heiferII_num = 0 self.cow_herd_exit_num = 0 diff --git a/RUFAS/biophysical/animal/herd_manager.py b/RUFAS/biophysical/animal/herd_manager.py index c80d1ba12d..ba54a2162c 100644 --- a/RUFAS/biophysical/animal/herd_manager.py +++ b/RUFAS/biophysical/animal/herd_manager.py @@ -131,6 +131,9 @@ def __init__( self.herd_statistics = HerdStatistics() self.herd_statistics.herd_num = animal_config_data["herd_information"]["herd_num"] + self.adjustment_period = animal_config_data["herd_information"]["herd_size_adjustment_period"] + self.selling_threshold = animal_config_data["herd_information"]["herd_size_sell_threshold"] + self.buying_threshold = animal_config_data["herd_information"]["herd_size_buy_threshold"] self.herd_reproduction_statistics = HerdReproductionStatistics() self.housing = animal_config_data["housing"] @@ -274,19 +277,6 @@ def phosphorus_concentration_by_animal_class(self) -> dict[AnimalType, float]: return phosphorus_concentration_by_animal_class - @property - def current_herd_size(self) -> int: - """ - Calculates the current size of the herd based on the number of heiferIIIs and cows. - - Returns - ------- - int - The current size of the herd. - - """ - return len(self.heiferIIIs) + len(self.cows) - @property def heiferII_events_by_id(self) -> dict[str, AnimalEvents]: """ @@ -601,18 +591,35 @@ def daily_routines( self._update_stillborn_calf_statistics(stillborn_newborn_calves) - removed_animals += self._check_if_heifers_need_to_be_sold(simulation_day=time.simulation_day) - newly_added_animals = self._check_if_replacement_heifers_needed(time=time) - self._update_herd_structure( - graduated_animals=graduated_animals, - newborn_calves=newborn_calves, - newly_added_animals=newly_added_animals, - removed_animals=removed_animals, - available_feeds=available_feeds, - current_day_conditions=weather.get_current_day_conditions(time), - total_inventory=total_inventory, - simulation_day=time.simulation_day, - ) + adjust_herd_size: bool = time.simulation_day > 0 and time.simulation_day % self.adjustment_period == 0 + if adjust_herd_size: + removed_animals += self._check_if_cows_need_to_be_sold( + simulation_day=time.simulation_day, removed_animal=removed_animals + ) + self._update_sold_and_died_cow_statistics(removed_animals) + newly_added_animals = self._check_if_replacement_heifers_needed(time=time) + + self._update_herd_structure( + graduated_animals=graduated_animals, + newborn_calves=newborn_calves, + newly_added_animals=newly_added_animals, + removed_animals=removed_animals, + available_feeds=available_feeds, + current_day_conditions=weather.get_current_day_conditions(time), + total_inventory=total_inventory, + simulation_day=time.simulation_day, + ) + else: + self._update_herd_structure( + graduated_animals=graduated_animals, + newborn_calves=newborn_calves, + newly_added_animals=[], + removed_animals=removed_animals, + available_feeds=available_feeds, + current_day_conditions=weather.get_current_day_conditions(time), + total_inventory=total_inventory, + simulation_day=time.simulation_day, + ) self.record_pen_history(time.simulation_day) enteric_methane_emission_by_pen: dict[str, float] = {} @@ -625,6 +632,25 @@ def daily_routines( self.update_herd_statistics() + no_milk_cow_num = len( + [ + cow + for cow in self.cows + if cow.milk_production.daily_milk_produced == 0 and cow.is_milking and cow.days_in_milk > 1 + ] + ) + + if no_milk_cow_num > 0: + self.om.add_warning( + "Warning: Lactating cows with no production.", + f"There are {no_milk_cow_num} lactating cows with no milking production on simulation" + f" day {time.simulation_day}.", + info_map={ + "class": self.__class__.__name__, + "function": self.daily_routines.__name__, + "simulation_day": time.simulation_day, + }, + ) AnimalModuleReporter.report_enteric_methane_emission(enteric_methane_emission_by_pen) AnimalModuleReporter.report_daily_animal_population(self.herd_statistics, time.simulation_day) AnimalModuleReporter.report_herd_statistics_data(self.herd_statistics, time.simulation_day) @@ -657,8 +683,7 @@ def _report_ration(self, simulation_day: int) -> None: def _create_newborn_calf(self, newborn_calf_config: NewBornCalfValuesTypedDict, simulation_day: int) -> Animal: """ - Creates a new newborn calf instance and records its entry event in the herd if it - is not sold. + Creates a new newborn calf instance and records its entry event in the herd if it is not sold. Parameters ---------- @@ -679,50 +704,49 @@ def _create_newborn_calf(self, newborn_calf_config: NewBornCalfValuesTypedDict, newborn_calf.events.add_event(newborn_calf.days_born, simulation_day, animal_constants.ENTER_HERD) return newborn_calf - def _check_if_heifers_need_to_be_sold( - self, - simulation_day: int, - ) -> list[Animal]: - """ - Checks if surplus heifers need to be sold based on herd size. + def _get_cow_removal_index(self, removed_animal: list[Animal]) -> int | None: + """Finds the indices of cows with the lowest daily milk production among cows that meet the specified + days-in-milk and days-pregnant criteria.""" + eligible_indices = [] - This method evaluates if the current number of heifers and cows exceeds a - specified threshold (defined as 3% over the herd statistics' target - herd size). If the threshold is surpassed, heiferIIIs are removed from the - herd until the herd size falls within the acceptable range. + for index, cow in enumerate(self.cows): + if cow in removed_animal: + continue + eligible_for_removal = ( + cow.days_in_milk > animal_constants.MIN_DIM_FOR_REMOVAL + and cow.days_in_pregnancy < animal_constants.MAX_DAYS_IN_PREG_FOR_REMOVAL + ) + if eligible_for_removal: + eligible_indices.append(index) - Parameters - ---------- - simulation_day : int - The simulation day on which the check and potential sale is conducted. + if not eligible_indices: + return None - Returns - ------- - list[Animal] - A list of heiferIIIs to be sold. + return min(eligible_indices, key=lambda i: self.cows[i].milk_production.daily_milk_produced) - """ + def _check_if_cows_need_to_be_sold(self, simulation_day: int, removed_animal: list[Animal]) -> list[Animal]: + """Checks if surplus cows need to be sold based on herd size.""" animals_removed: list[Animal] = [] - while ( - self.current_herd_size > self.herd_statistics.herd_num * animal_constants.SELLING_THRESHOLD - and len(self.heiferIIIs) > 0 - ): - removed_heiferIII = self.heiferIIIs.pop() - animals_removed.append(removed_heiferIII) - removed_heiferIII.sold_at_day = simulation_day - self.herd_statistics.sold_heiferIIIs_info.append( - SoldAnimalTypedDict( - id=removed_heiferIII.id, - animal_type=removed_heiferIII.animal_type.value, - sold_at_day=removed_heiferIII.sold_at_day, - body_weight=removed_heiferIII.body_weight, - cull_reason="NA", - days_in_milk="NA", - parity="NA", + + while len(self.cows) > self.selling_threshold and len(self.cows) > 0: + remove_index = self._get_cow_removal_index(removed_animal) + + if remove_index is None: + info_map = { + "class": self.__class__.__name__, + "function": self._check_if_cows_need_to_be_sold.__name__, + "simulation_day": simulation_day, + } + self.om.add_error( + "Unable to adjust herd size", "There are no cow that's qualified to be sold.", info_map ) - ) - self.herd_statistics.sold_heiferIII_oversupply_num += 1 - self.herd_statistics.heiferIII_num -= 1 + break + + removed_cow = self.cows.pop(remove_index) + removed_cow.sold_at_day = simulation_day + removed_cow.cull_reason = "culled for herd resize" + animals_removed.append(removed_cow) + return animals_removed def _check_if_replacement_heifers_needed(self, time: RufasTime) -> list[Animal]: @@ -746,9 +770,7 @@ def _check_if_replacement_heifers_needed(self, time: RufasTime) -> list[Animal]: """ animals_added: list[Animal] = [] while ( - self.current_herd_size + self.herd_statistics.bought_heifer_num - < self.herd_statistics.herd_num * animal_constants.BUYING_THRESHOLD - and time.simulation_day > 1 + len(self.cows) + self.herd_statistics.bought_heifer_num < self.buying_threshold and time.simulation_day > 1 ): if len(self.replacement_market) == 0: break @@ -1810,6 +1832,7 @@ def _update_sold_and_died_cow_statistics(self, sold_and_died_cows: list[Animal]) sum_cow_culling_age = self.herd_statistics.avg_cow_culling_age * self.herd_statistics.cow_herd_exit_num + sum( [cow.days_born for cow in sold_and_died_cows] ) + self.herd_statistics.cow_num -= len(sold_and_died_cows) self.herd_statistics.cow_herd_exit_num += len(sold_and_died_cows) self.herd_statistics.avg_cow_culling_age = ( (sum_cow_culling_age / self.herd_statistics.cow_herd_exit_num) @@ -1834,6 +1857,9 @@ def _update_sold_and_died_cow_statistics(self, sold_and_died_cows: list[Animal]) [cow for cow in sold_and_died_cows if cow.cull_reason == cull_reason] ) + oversupply_cows_num = sum(cow.cull_reason == animal_constants.OVERSUPPLY_CULL for cow in sold_and_died_cows) + self.herd_statistics.sold_cow_oversupply_num += oversupply_cows_num + sold_cows: list[Animal] = [cow for cow in sold_and_died_cows if cow.cull_reason != animal_constants.DEATH_CULL] self.herd_statistics.sold_cows_info += [ SoldAnimalTypedDict( diff --git a/RUFAS/biophysical/field/soil/infiltration.py b/RUFAS/biophysical/field/soil/infiltration.py index 443d69596d..d6fc49dc5e 100644 --- a/RUFAS/biophysical/field/soil/infiltration.py +++ b/RUFAS/biophysical/field/soil/infiltration.py @@ -101,7 +101,7 @@ def infiltrate(self, rainfall: float) -> None: # --- static methods --- @staticmethod - def _determine_first_moisture_condition_parameter(second_moisture_condition: float): + def _determine_first_moisture_condition_parameter(second_moisture_condition: float) -> float: """ Determine the curve number for dry (wilting point) conditions. @@ -125,7 +125,7 @@ def _determine_first_moisture_condition_parameter(second_moisture_condition: flo return second_moisture_condition - (top / bottom) @staticmethod - def _determine_third_moisture_condition_parameter(second_moisture_condition: float): + def _determine_third_moisture_condition_parameter(second_moisture_condition: float) -> float: """ Determine the curve number for wet (field capacity) conditions. diff --git a/changelog.md b/changelog.md index 2e2d892faa..a5a6ba9339 100644 --- a/changelog.md +++ b/changelog.md @@ -46,6 +46,7 @@ v1.0.0 - [2848](https://github.com/RuminantFarmSystems/MASM/pull/2848) - [minor change] [NoInputChange] [NoOutputChange] Justify `deepcopy()` usage. - [2843](https://github.com/RuminantFarmSystems/MASM/pull/2843) - [minor change] [NoInputChange] [NoOutputChange] Fix Simple `#noqa`s in codebase. - [2852](https://github.com/RuminantFarmSystems/MASM/pull/2852) - [minor change] [NoInputChange] [NoOutputChange] Fix AssertionError on `dev`. +- [2622](https://github.com/RuminantFarmSystems/MASM/pull/2622) - [minor change] [InputChange][OutputChange] Update culling logic to respond to number of available heifers. - [2866](https://github.com/RuminantFarmSystems/MASM/pull/2866) - [minor change] [NoInputChange] [NoOutputChange] Clears all mypy errors in test_field_manager.py. - [2863](https://github.com/RuminantFarmSystems/MASM/pull/2863) - [minor change] [NoInputChange] [NoOutputChange] Updates TaskManager to avoid using multiprocessing when running single tasks. - [2867](https://github.com/RuminantFarmSystems/MASM/pull/2867) - [minor change] [NoInputChange] [NoOutputChange] Updates expand_data_temporally() util function to offer options of full simulation expansion and front-padding data. @@ -345,6 +346,7 @@ v1.0.0 - [2744](https://github.com/RuminantFarmSystems/RuFaS/pull/2744) - [minor change] [NoInputChange] [NoOutputChange] Update the OM and RG wiki with new report filter options. - [2881](https://github.com/RuminantFarmSystems/RuFaS/pull/2881) - [minor change] [NoInputChange] [NoOutputChange] Add v1.0.0 release notes. + ### v0.9.2 - [1968](https://github.com/RuminantFarmSystems/RuFaS/pull/1968) - [minor change] [Changelog] Move the changelog from a Google sheet into a markdown document in the repository. diff --git a/input/data/animal/example_freestall_animal.json b/input/data/animal/example_freestall_animal.json index beb4a2f956..06ad135b05 100644 --- a/input/data/animal/example_freestall_animal.json +++ b/input/data/animal/example_freestall_animal.json @@ -7,6 +7,9 @@ "cow_num": 100, "replace_num": 500, "herd_num": 100, + "herd_size_adjustment_period": 30, + "herd_size_sell_threshold": 101, + "herd_size_buy_threshold": 95, "breed": "HO", "parity_fractions": { "1": 0.36, @@ -31,7 +34,6 @@ "days_in_preg_when_dry": 218, "heifer_repro_cull_time": 500, "do_not_breed_time": 185, - "cull_milk_production": 30, "cow_times_milked_per_day": 3, "milk_fat_percent": 4, "milk_protein_percent": 3.2 diff --git a/input/data/animal/example_open_lot_animal.json b/input/data/animal/example_open_lot_animal.json index d7f95ffc4d..be78c46404 100644 --- a/input/data/animal/example_open_lot_animal.json +++ b/input/data/animal/example_open_lot_animal.json @@ -7,6 +7,9 @@ "cow_num": 1000, "replace_num": 5000, "herd_num": 1000, + "herd_size_adjustment_period": 30, + "herd_size_sell_threshold": 1010, + "herd_size_buy_threshold": 980, "breed": "HO", "parity_fractions": { "1": 0.35, @@ -31,7 +34,6 @@ "days_in_preg_when_dry": 218, "heifer_repro_cull_time": 500, "do_not_breed_time": 185, - "cull_milk_production": 30, "cow_times_milked_per_day": 3, "milk_fat_percent": 4, "milk_protein_percent": 3.2 diff --git a/input/metadata/properties/default.json b/input/metadata/properties/default.json index 01e7294c9c..4d626c5eba 100644 --- a/input/metadata/properties/default.json +++ b/input/metadata/properties/default.json @@ -84,6 +84,22 @@ "default": 100, "minimum": 6 }, + "herd_size_adjustment_period": { + "type": "number", + "description": "The period between each check to adjust the herd's size.", + "default": 30, + "minimum": 1 + }, + "herd_size_sell_threshold": { + "type": "number", + "description": "The threshold that should sell animals to maintain herd size.", + "minimum": 1 + }, + "herd_size_buy_threshold": { + "type": "number", + "description": "The threshold that should buy animals to maintain herd size.", + "minimum": 0 + }, "breed": { "type": "string", "default": "HO", @@ -203,12 +219,6 @@ "default": 185, "minimum": 0 }, - "cull_milk_production": { - "type": "number", - "description": "Cull Milk Production (kg/d) -- Milk production threshold at which 'do not breed' cows are culled if they fall below", - "default": 30, - "minimum": 0 - }, "cow_times_milked_per_day": { "type": "number", "description": "Number of Milkings (per day) -- The average or most common number of times cows are milked per day (1, 2, or 3 times daily)", diff --git a/tests/test_biophysical/test_animal/test_animal/test_animal.py b/tests/test_biophysical/test_animal/test_animal/test_animal.py index 87d44f349e..e4d24cf112 100644 --- a/tests/test_biophysical/test_animal/test_animal/test_animal.py +++ b/tests/test_biophysical/test_animal/test_animal/test_animal.py @@ -2544,56 +2544,6 @@ def test_animal_life_stage_update_not_cow( ) -@pytest.mark.parametrize("future_cull_date,future_death_date,expected_status", [(15, 15, AnimalStatus.SOLD)]) -def test_animal_life_stage_update_low_production( - mock_lactating_cow: Animal, - mocker: MockerFixture, - future_cull_date: int, - future_death_date: int, - expected_status: AnimalStatus, -) -> None: - mock_lactating_cow.animal_type = AnimalType.LAC_COW - mock_lactating_cow.future_cull_date = future_cull_date - mock_lactating_cow.future_death_date = future_death_date - mock_lactating_cow.reproduction.do_not_breed = True - mock_lactating_cow.milk_production.daily_milk_produced = 5 - mocker.patch.object(RufasTime, "simulation_day", new_callable=PropertyMock, return_value=5) - time = RufasTime(datetime(year=1999, month=1, day=2), datetime(year=2000, month=1, day=1)) - mock_update = mocker.patch.object( - mock_lactating_cow, - "_cow_life_stage_update", - return_value=( - AnimalStatus.LIFE_STAGE_CHANGED, - NewBornCalfValuesTypedDict( - breed="test_breed", - animal_type="test_type", - birth_date="test_bd", - days_born=5, - birth_weight=15.3, - initial_phosphorus=18.4, - net_merit=75.1, - ), - ), - ) - - status, output = mock_lactating_cow.animal_life_stage_update(time) - - mock_update.assert_called_once() - - assert mock_lactating_cow.cull_reason == animal_constants.LOW_PROD_CULL - assert mock_lactating_cow.sold_at_day == 5 - assert status == AnimalStatus.SOLD - assert output == NewBornCalfValuesTypedDict( - breed="test_breed", - animal_type="test_type", - birth_date="test_bd", - days_born=5, - birth_weight=15.3, - initial_phosphorus=18.4, - net_merit=75.1, - ) - - @pytest.mark.parametrize("born_days, expected", [(10, False), (60, True)]) def test_evaluate_calf_for_heiferI(mock_lactating_cow: Animal, born_days: int, expected: bool) -> None: mock_lactating_cow.days_born = born_days diff --git a/tests/test_biophysical/test_animal/test_herd_manager/pytest_fixtures.py b/tests/test_biophysical/test_animal/test_herd_manager/pytest_fixtures.py index 87271901e2..877a358001 100644 --- a/tests/test_biophysical/test_animal/test_herd_manager/pytest_fixtures.py +++ b/tests/test_biophysical/test_animal/test_herd_manager/pytest_fixtures.py @@ -43,6 +43,9 @@ def config_json() -> dict[str, Any]: def animal_json() -> dict[str, Any]: return { "herd_information": { + "herd_size_adjustment_period": 30, + "herd_size_sell_threshold": 103, + "herd_size_buy_threshold": 101, "calf_num": 8, "heiferI_num": 44, "heiferII_num": 38, diff --git a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_daily_routines.py b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_daily_routines.py index 0194cab1c5..d1ed978627 100644 --- a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_daily_routines.py +++ b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_daily_routines.py @@ -380,14 +380,6 @@ def test_daily_routines(herd_manager: HerdManager, mock_herd: dict[str, list[Ani sold_oversupply_heiferIIIs = [mock_animal(AnimalType.HEIFER_III, sold=True) for _ in range(5)] bought_replacement_heiferIIIs = [mock_animal(AnimalType.HEIFER_III, sold=False) for _ in range(5)] - graduated_animals = ( - graduated_calves + graduated_heiferIs + graduated_heiferIIs + graduated_heiferIIIs + graduated_cows - ) - newborn_calves = heiferIII_newborn_calves + cow_newborn_calves - removed_animals = ( - sold_calves + sold_heiferIs + sold_heiferIIs + sold_heiferIIIs + sold_and_died_cows + sold_oversupply_heiferIIIs - ) - mock_perform_daily_routines_for_animals_side_effect: list[ tuple[list[Animal], list[Animal], list[Animal], list[Animal], list[Animal]] ] = [ @@ -405,8 +397,8 @@ def test_daily_routines(herd_manager: HerdManager, mock_herd: dict[str, list[Ani side_effect=mock_perform_daily_routines_for_animals_side_effect, ) mock_update_sold_animal_statistics = mocker.patch.object(herd_manager, "_update_sold_animal_statistics") - mock_check_if_heifers_need_to_be_sold = mocker.patch.object( - herd_manager, "_check_if_heifers_need_to_be_sold", return_value=sold_oversupply_heiferIIIs + mock_check_if_cows_need_to_be_sold = mocker.patch.object( + herd_manager, "_check_if_cows_need_to_be_sold", return_value=sold_oversupply_heiferIIIs ) mock_check_if_replacement_heifers_needed = mocker.patch.object( herd_manager, "_check_if_replacement_heifers_needed", return_value=bought_replacement_heiferIIIs @@ -451,18 +443,9 @@ def test_daily_routines(herd_manager: HerdManager, mock_herd: dict[str, list[Ani mock_update_sold_animal_statistics.assert_called_once_with( sold_newborn_calves=[], sold_heiferIIs=sold_heiferIIs, sold_and_died_cows=sold_and_died_cows ) - mock_check_if_heifers_need_to_be_sold.assert_called_once_with(simulation_day=mock_time.simulation_day) - mock_check_if_replacement_heifers_needed.assert_called_once_with(time=mock_time) - mock_update_herd_structure.assert_called_once_with( - graduated_animals=graduated_animals, - newborn_calves=newborn_calves, - newly_added_animals=bought_replacement_heiferIIIs, - removed_animals=removed_animals, - available_feeds=[mock_feed], - current_day_conditions=mock_weather.get_current_day_conditions(), - total_inventory=mock_total_inventory, - simulation_day=15, - ) + assert mock_check_if_cows_need_to_be_sold.call_count == 0 + assert mock_check_if_replacement_heifers_needed.call_count == 0 + assert mock_update_herd_structure.call_count == 1 mock_record_pen_history.assert_called_once_with(mock_time.simulation_day) mock_update_herd_statistics.assert_called_once_with() mock_report_manure_streams.assert_called_once() @@ -505,44 +488,101 @@ def test_create_newborn_calf( animal.events.add_event.assert_called_once() -def test_check_if_heifers_need_to_be_sold( - mock_get_data_side_effect: list[Any], mocker: MockerFixture, mock_herd: dict[str, list[Animal]] -) -> None: - """Unit test for _check_if_heifers_need_to_be_sold()""" - herd_manager, _ = mock_herd_manager( - calves=mock_herd["calves"], - heiferIs=mock_herd["heiferIs"], - heiferIIs=mock_herd["heiferIIs"], - heiferIIIs=mock_herd["heiferIIIs"] * 25, - cows=mock_herd["dry_cows"] + mock_herd["lac_cows"], - replacement=mock_herd["replacement"], - mocker=mocker, - mock_get_data_side_effect=mock_get_data_side_effect, - ) - herd_manager.herd_statistics.heiferIII_num, herd_manager.herd_statistics.cow_num = ( - len(herd_manager.heiferIIIs), - len(herd_manager.cows), - ) +def _create_sortable_mock_cow( + id_val: int, is_dnb: bool, daily_milk: float, days_in_milk: int, days_in_pregnancy: int +) -> MagicMock: + """Helper to create a mock cow with specific sorting attributes.""" + cow = MagicMock(spec=Animal) + cow.id = id_val + cow.animal_type = AnimalType.LAC_COW + cow.body_weight = 600.0 + cow.sold_at_day = None - result = herd_manager._check_if_heifers_need_to_be_sold(simulation_day=0) - - expected_sold_heiferIIIs = mock_herd["heiferIIIs"][::-1][:3] - expected_sold_heiferIIIs_info = [ - { - "id": removed_heiferIII.id, - "animal_type": removed_heiferIII.animal_type.value, - "sold_at_day": removed_heiferIII.sold_at_day, - "body_weight": removed_heiferIII.body_weight, - "cull_reason": "NA", - "days_in_milk": "NA", - "parity": "NA", - } - for removed_heiferIII in expected_sold_heiferIIIs[:3] - ] - assert result == expected_sold_heiferIIIs - assert herd_manager.herd_statistics.sold_heiferIIIs_info == expected_sold_heiferIIIs_info - assert herd_manager.herd_statistics.heiferIII_num == 97 - assert herd_manager.herd_statistics.sold_heiferIII_oversupply_num == 3 + cow.reproduction = MagicMock() + cow.reproduction.do_not_breed = is_dnb + cow.reproduction.calves = 1 + + cow.milk_production = MagicMock() + cow.milk_production.daily_milk_produced = daily_milk + + cow.days_in_milk = days_in_milk + cow.days_in_pregnancy = days_in_pregnancy + return cow + + +def test_check_if_cows_need_to_be_sold_comprehensive(herd_manager: HerdManager, mocker: MockerFixture) -> None: + """ + Unit test for _check_if_cows_need_to_be_sold(). + + Verifies: + 1. All eligible cows are ranked only by lowest milk production. + 2. Cows with DIM <= 60 are protected (skipped). + 3. Cows with days in pregnancy >= 180 are protected (skipped). + 4. Error is logged if herd is too large but no cows are eligible. + 5. Statistics are updated correctly based on source code logic. + """ + HERD_TARGET = 10 + SELLING_THRESHOLD = 10 + SIMULATION_DAY = 100 + + herd_manager.herd_statistics.herd_num = HERD_TARGET + herd_manager.selling_threshold = SELLING_THRESHOLD + herd_manager.herd_statistics.cow_num = 15 + herd_manager.herd_statistics.sold_cow_oversupply_num = 0 + herd_manager.herd_statistics.sold_cow_num = 0 + herd_manager.herd_statistics.cow_herd_exit_num = 10 + + cow_dnb_low_milk = _create_sortable_mock_cow(1, True, 10.0, 100, 50) + cow_dnb_high_milk = _create_sortable_mock_cow(2, True, 50.0, 100, 60) + cow_normal_low_milk = _create_sortable_mock_cow(3, False, 20.0, 100, 70) + cow_normal_high_milk = _create_sortable_mock_cow(4, False, 40.0, 100, 80) + cow_protected_low_dim = _create_sortable_mock_cow(5, False, 5.0, 10, 0) + cow_protected_high_preg = _create_sortable_mock_cow(6, False, 1.0, 200, 180) + + fillers = [] + for i in range(10): + fillers.append(_create_sortable_mock_cow(10 + i, False, 100.0, 200, 20)) + + fillers[0].milk_production.daily_milk_produced = 90.0 + + all_cows = [ + cow_dnb_low_milk, + cow_dnb_high_milk, + cow_normal_low_milk, + cow_normal_high_milk, + cow_protected_low_dim, + cow_protected_high_preg, + ] + fillers + + herd_manager.cows = all_cows + + mock_om_add_error = mocker.patch.object(herd_manager.om, "add_error") + + removed_cows = herd_manager._check_if_cows_need_to_be_sold(SIMULATION_DAY, []) + + assert len(removed_cows) == 6 + assert len(herd_manager.cows) == 10 + + assert removed_cows[0] == cow_dnb_low_milk + assert removed_cows[1] == cow_normal_low_milk + assert removed_cows[2] == cow_normal_high_milk + assert removed_cows[3] == cow_dnb_high_milk + assert removed_cows[4] == fillers[0] + + assert cow_protected_low_dim in herd_manager.cows + assert cow_protected_high_preg in herd_manager.cows + + herd_manager.herd_statistics.herd_num = 1 + herd_manager.selling_threshold = 1.0 + + cow_prot_1 = _create_sortable_mock_cow(99, False, 100.0, 10, 0) + cow_prot_2 = _create_sortable_mock_cow(98, False, 100.0, 10, 0) + herd_manager.cows = [cow_prot_1, cow_prot_2] + + stuck_result = herd_manager._check_if_cows_need_to_be_sold(SIMULATION_DAY, []) + + assert len(stuck_result) == 0 + mock_om_add_error.assert_called_once() def test_check_if_replacement_heifers_needed( diff --git a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_herd_statistics.py b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_herd_statistics.py index 6bc3408498..f2d76e38a6 100644 --- a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_herd_statistics.py +++ b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_herd_statistics.py @@ -169,7 +169,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st ( { animal_constants.DEATH_CULL: 0, - animal_constants.LOW_PROD_CULL: 0, + animal_constants.OVERSUPPLY_CULL: 0, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -180,7 +180,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st 0, { animal_constants.DEATH_CULL: 0.0, - animal_constants.LOW_PROD_CULL: 0.0, + animal_constants.OVERSUPPLY_CULL: 0.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -193,7 +193,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st ( { animal_constants.DEATH_CULL: 5, - animal_constants.LOW_PROD_CULL: 0, + animal_constants.OVERSUPPLY_CULL: 0, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -204,7 +204,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st 5, { animal_constants.DEATH_CULL: 100.0, - animal_constants.LOW_PROD_CULL: 0.0, + animal_constants.OVERSUPPLY_CULL: 0.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -218,7 +218,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st ( { animal_constants.DEATH_CULL: 5, - animal_constants.LOW_PROD_CULL: 5, + animal_constants.OVERSUPPLY_CULL: 5, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -229,7 +229,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st 10, { animal_constants.DEATH_CULL: 50.0, - animal_constants.LOW_PROD_CULL: 50.0, + animal_constants.OVERSUPPLY_CULL: 50.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -243,7 +243,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st ( { animal_constants.DEATH_CULL: 3, - animal_constants.LOW_PROD_CULL: 2, + animal_constants.OVERSUPPLY_CULL: 2, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -254,7 +254,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st 10, { animal_constants.DEATH_CULL: 30.0, - animal_constants.LOW_PROD_CULL: 20.0, + animal_constants.OVERSUPPLY_CULL: 20.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -269,7 +269,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st ( { animal_constants.DEATH_CULL: 2, - animal_constants.LOW_PROD_CULL: 0, + animal_constants.OVERSUPPLY_CULL: 0, animal_constants.LAMENESS_CULL: 0, animal_constants.INJURY_CULL: 0, animal_constants.MASTITIS_CULL: 0, @@ -280,7 +280,7 @@ def test_calculate_cow_percentages(herd_manager: HerdManager, mock_herd: dict[st 10, { animal_constants.DEATH_CULL: 20.0, - animal_constants.LOW_PROD_CULL: 0.0, + animal_constants.OVERSUPPLY_CULL: 0.0, animal_constants.LAMENESS_CULL: 0.0, animal_constants.INJURY_CULL: 0.0, animal_constants.MASTITIS_CULL: 0.0, @@ -533,7 +533,7 @@ def test_update_sold_and_died_cow_statistics( ) -> None: """Unit test for _update_sold_and_died_cow_statistics()""" cull_reasons = [ - animal_constants.LOW_PROD_CULL, + animal_constants.OVERSUPPLY_CULL, animal_constants.LAMENESS_CULL, animal_constants.INJURY_CULL, animal_constants.MASTITIS_CULL, @@ -604,7 +604,7 @@ def test_update_sold_and_died_cow_statistics( current_cull_reason_stats = { animal_constants.DEATH_CULL: randint(0, num_total_sold_and_died_cows), - animal_constants.LOW_PROD_CULL: randint(0, num_total_sold_and_died_cows), + animal_constants.OVERSUPPLY_CULL: randint(0, num_total_sold_and_died_cows), animal_constants.LAMENESS_CULL: randint(0, num_total_sold_and_died_cows), animal_constants.INJURY_CULL: randint(0, num_total_sold_and_died_cows), animal_constants.MASTITIS_CULL: randint(0, num_total_sold_and_died_cows), @@ -616,8 +616,8 @@ def test_update_sold_and_died_cow_statistics( expected_cull_reason_stats = { animal_constants.DEATH_CULL: current_cull_reason_stats[animal_constants.DEATH_CULL] + len([cow for cow in sold_and_died_cows if cow.cull_reason == animal_constants.DEATH_CULL]), - animal_constants.LOW_PROD_CULL: current_cull_reason_stats[animal_constants.LOW_PROD_CULL] - + len([cow for cow in sold_and_died_cows if cow.cull_reason == animal_constants.LOW_PROD_CULL]), + animal_constants.OVERSUPPLY_CULL: current_cull_reason_stats[animal_constants.OVERSUPPLY_CULL] + + len([cow for cow in sold_and_died_cows if cow.cull_reason == animal_constants.OVERSUPPLY_CULL]), animal_constants.LAMENESS_CULL: current_cull_reason_stats[animal_constants.LAMENESS_CULL] + len([cow for cow in sold_and_died_cows if cow.cull_reason == animal_constants.LAMENESS_CULL]), animal_constants.INJURY_CULL: current_cull_reason_stats[animal_constants.INJURY_CULL] diff --git a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_initialization.py b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_initialization.py index 3516a18eca..0aeb0ccd73 100644 --- a/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_initialization.py +++ b/tests/test_biophysical/test_animal/test_herd_manager/test_herd_manager_initialization.py @@ -100,7 +100,12 @@ def test_init_uses_set_ration_feeds_when_not_user_defined(mocker: MockerFixture) } animal_data: dict[str, Any] = { - "herd_information": {"herd_num": 123}, + "herd_information": { + "herd_num": 123, + "herd_size_adjustment_period": 30, + "herd_size_sell_threshold": 101, + "herd_size_buy_threshold": 106, + }, "housing": "barn", "pasture_concentrate": 0, "ration": {"formulation_interval": 7, "maximum_ration_reformulation_attempts": 250},