diff --git a/README.md b/README.md index c378c18f0c..10de5cb740 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-1135%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Mypy](https://img.shields.io/badge/Mypy-1134%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems diff --git a/RUFAS/biophysical/field/crop/crop.py b/RUFAS/biophysical/field/crop/crop.py index 224ab3bb05..a35798845c 100644 --- a/RUFAS/biophysical/field/crop/crop.py +++ b/RUFAS/biophysical/field/crop/crop.py @@ -233,7 +233,7 @@ def manage_crop_harvest( field_size: float, time: RufasTime, soil_data: SoilData, - ) -> HarvestedCrop: + ) -> HarvestedCrop | None: """Wrapper function for the Crop's CropManagement harvesting operation. Parameters @@ -251,8 +251,9 @@ def manage_crop_harvest( Returns ------- - HarvestedCrop - A harvested crop data structure. + HarvestedCrop | None + A harvested crop data structure, or ``None`` when the operation collects no yield (a kill-only operation + or a crop configured to deposit all residue into the field). """ return self._crop_management.manage_harvest(harvest_operation, field_name, field_size, time, soil_data) diff --git a/RUFAS/biophysical/field/crop/crop_data.py b/RUFAS/biophysical/field/crop/crop_data.py index 75bb7fe35e..76302ba924 100644 --- a/RUFAS/biophysical/field/crop/crop_data.py +++ b/RUFAS/biophysical/field/crop/crop_data.py @@ -69,6 +69,9 @@ class CropData: grain. use_heat_scheduling : bool If heat unit scheduling is used for harvesting. + deposit_all_residue_at_harvest : bool, default False + If True, harvest operations collect no yield and deposit all cut biomass into the field as residue instead of + removing it as harvested yield. harvest_heat_fraction : float Fraction of potential heat units for optimal growth stage for harvest. optimal_harvest_index : float @@ -235,6 +238,7 @@ class CropData: planting_day: int = 100 lignin_dry_matter_percentage: float = 1.518 use_heat_scheduling: bool = False + deposit_all_residue_at_harvest: bool = False harvest_heat_fraction: float = 1.10 optimal_harvest_index: float = 0.5 minimum_harvest_index: float = 0.3 diff --git a/RUFAS/biophysical/field/crop/crop_data_factory.py b/RUFAS/biophysical/field/crop/crop_data_factory.py index f7dd033b11..06c0bc81ed 100644 --- a/RUFAS/biophysical/field/crop/crop_data_factory.py +++ b/RUFAS/biophysical/field/crop/crop_data_factory.py @@ -1,4 +1,4 @@ -from typing import Any, TypedDict +from typing import Any, NotRequired, TypedDict from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -15,6 +15,7 @@ class CropConfiguration(TypedDict): name: str plant_category: PlantCategory is_nitrogen_fixer: bool + deposit_all_residue_at_harvest: NotRequired[bool] minimum_temperature: float optimal_temperature: float potential_heat_units: float diff --git a/RUFAS/biophysical/field/crop/crop_management.py b/RUFAS/biophysical/field/crop/crop_management.py index 08810fe7b8..f405d17fd2 100644 --- a/RUFAS/biophysical/field/crop/crop_management.py +++ b/RUFAS/biophysical/field/crop/crop_management.py @@ -118,7 +118,7 @@ def manage_harvest( field_size: float, time: RufasTime, soil_data: SoilData, - ) -> HarvestedCrop: + ) -> HarvestedCrop | None: """ Executes the harvest operation passed on the crop that contains this module. @@ -137,17 +137,21 @@ def manage_harvest( Returns ------- - HarvestedCrop + HarvestedCrop | None The harvested crop data structure containing mass and nutritional information associated with the - harvest's yield. + harvest's yield. This is ``None`` when no yield is collected, i.e. for a kill-only operation or for a crop + configured to deposit all residue into the field (see ``CropData.deposit_all_residue_at_harvest``). """ self.determine_harvest_index() harvested_crop = None if harvest_operation in (HarvestOperation.HARVEST_KILL, HarvestOperation.HARVEST_ONLY): - self.cut_crop(collected_fraction=self.harvest_efficiency) - harvested_crop = self._get_harvested_crop(time, field_size, field_name) + if self.data.deposit_all_residue_at_harvest: + self.cut_crop(collected_fraction=0.0) + else: + self.cut_crop(collected_fraction=self.harvest_efficiency) + harvested_crop = self._get_harvested_crop(time, field_size, field_name) if harvest_operation in (HarvestOperation.KILL_ONLY, HarvestOperation.HARVEST_KILL): self.kill() diff --git a/RUFAS/biophysical/field/field/field.py b/RUFAS/biophysical/field/field/field.py index 1991fd1416..bc0c6bbf47 100644 --- a/RUFAS/biophysical/field/field/field.py +++ b/RUFAS/biophysical/field/field/field.py @@ -1253,7 +1253,7 @@ def _harvest_heat_scheduled_crops(self, rainfall: float, time: RufasTime) -> lis harvested_crops = [] for crop in self.crops: if crop.should_harvest_based_on_heat(): - harvested_crop: HarvestedCrop = crop.manage_crop_harvest( + harvested_crop: HarvestedCrop | None = crop.manage_crop_harvest( HarvestOperation.HARVEST_ONLY, self.field_data.name, self.field_data.field_size, @@ -1447,7 +1447,7 @@ def _harvest_crop( harvested_crops = [] for crop in crops_to_be_harvested: - harvested_crop: HarvestedCrop = crop.manage_crop_harvest( + harvested_crop: HarvestedCrop | None = crop.manage_crop_harvest( harvest_operation, self.field_data.name, self.field_data.field_size, diff --git a/RUFAS/input/metadata/properties/default.json b/RUFAS/input/metadata/properties/default.json index 20974295b0..84b3976cef 100644 --- a/RUFAS/input/metadata/properties/default.json +++ b/RUFAS/input/metadata/properties/default.json @@ -2647,6 +2647,11 @@ "type": "bool", "description": "Whether the crop configuration is a nitrogen fixer." }, + "deposit_all_residue_at_harvest": { + "type": "bool", + "description": "Whether harvest operations deposit all cut biomass into the field as residue instead of removing it as harvested yield. Set to true for grazed crops such as pastures, where forage removal is handled by grazing rather than by the harvest operation.", + "default": false + }, "minimum_temperature": { "type": "number", "description": "Minimum temperature for plant growth (Celsius)." diff --git a/changelog_WIP.md b/changelog_WIP.md index f22adb131d..cc4307a2ee 100644 --- a/changelog_WIP.md +++ b/changelog_WIP.md @@ -106,6 +106,7 @@ This **WIP Changelog** records development changes in progress and not yet inclu - [3165](https://github.com/RuminantFarmSystems/RuFaS/pull/3165) - [minor change] [Dependabot] [NoInputChange] [NoOutputChange] Adds a `dependabot.yml` file to trigger more regular scans for dependency updates and includes a `CODEOWNERS` file in `github/` to enforce tagging the dev-team for review on dependabot-generated PRs. - [3162](https://github.com/RuminantFarmSystems/RuFaS/pull/3162) - [minor change] [Cross Validation] [Crop and Soil] [DataValidator] [NoInputChange] [NoOutputChange] Adds cross-validation rules requiring field `tractor_size` to be non-null when simulating fields without animals, and fixes `CrossValidator` `is_not_null` rules raising "Unknown alias name" instead of failing cleanly when evaluating an actual null. - [3128](https://github.com/RuminantFarmSystems/RuFaS/pull/3128) - [minor change] [Animal] [NoInputChange] [NoOutputChange] Clamps `Growth._calculate_pregnant_heifer_target_daily_growth()` to `AnimalModuleConstants.MINIMUM_HEIFER_DAILY_GROWTH_RATE`, preventing negative target daily gain for pregnant heifers near or at term. +- [3163](https://github.com/RuminantFarmSystems/RuFaS/pull/3163) - [minor change] [Crop and Soil] [Properties] [InputChange] [NoOutputChange] Adds a `pasture` crop configuration (modeled after tall fescue) for grazing and a `deposit_all_residue_at_harvest` crop-configuration flag. - [3150](https://github.com/RuminantFarmSystems/RuFaS/pull/3150) - [minor change] [Crop and Soil] [NoInputChange] [OutputChange] Re-implements `MineralizationDecomposition._calculate_nutrient_cycling_residue_composition_factor` to return the SWAT 3:1.2.8 value. - [3166](https://github.com/RuminantFarmSystems/RuFaS/pull/3166) - [minor change] [Dependency - Black] [NoInputChange] [NoOutputChange] Updates minimum Black version in `pyproject.toml` file dev section from 25.1.0 to 26.5.1. - [3099](https://github.com/RuminantFarmSystems/RuFaS/pull/3099) - [minor change] [Animal] [NoInputChange] [NoOutputChange] Adds a summary warning in `HerdManager.formulate_rations()` that reports the number of cows whose milk production was reduced due to ration formulation failure and the average reduction (kg), logged once per ration formulation interval via `OutputManager.add_warning()`. diff --git a/input/data/crop_configurations/default_crop_configs.json b/input/data/crop_configurations/default_crop_configs.json index e3100ef048..206035b367 100644 --- a/input/data/crop_configurations/default_crop_configs.json +++ b/input/data/crop_configurations/default_crop_configs.json @@ -518,6 +518,44 @@ "yield_nitrogen_fraction": 0.021248, "yield_phosphorus_fraction": 0.00281 }, + { + "name": "pasture", + "plant_category": "perennial", + "is_nitrogen_fixer": false, + "deposit_all_residue_at_harvest": true, + "minimum_temperature": 0.0, + "optimal_temperature": 15.0, + "potential_heat_units": 800.0, + "max_leaf_area_index": 4.0, + "first_heat_fraction_point": 0.15, + "first_leaf_fraction_point": 0.01, + "second_heat_fraction_point": 0.50, + "second_leaf_fraction_point": 0.95, + "senescent_heat_fraction": 0.80, + "light_use_efficiency": 30.0, + "emergence_nitrogen_fraction": 0.0560, + "half_mature_nitrogen_fraction": 0.0210, + "mature_nitrogen_fraction": 0.0120, + "emergence_phosphorus_fraction": 0.0099, + "half_mature_phosphorus_fraction": 0.0022, + "mature_phosphorus_fraction": 0.0019, + "max_root_depth": 2000.0, + "root_distribution_param_da": 137.0, + "root_distribution_param_c": -1.144, + "optimal_harvest_index": 0.85, + "minimum_harvest_index": 0.37, + "dry_matter_percentage": 88.331, + "lignin_dry_matter_percentage": 4.167, + "crude_protein_percent_at_harvest": 13.28, + "non_protein_nitrogen_at_harvest": 4.056, + "starch_at_harvest": 2.217, + "adf_at_harvest": 35.524, + "ndf_at_harvest": 58.007, + "sugar_at_harvest": 15.235, + "ash_at_harvest": 8.567, + "yield_nitrogen_fraction": 0.021248, + "yield_phosphorus_fraction": 0.00281 + }, { "name": "triticale_grain", "plant_category": "cool_annual", diff --git a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data.py b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data.py index 793a07d2d7..859ea68931 100644 --- a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data.py +++ b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data.py @@ -12,6 +12,11 @@ def mock_crop_data() -> CropData: return CropData(**SAMPLE_CROP_CONFIGURATION) +def test_deposit_all_residue_at_harvest_defaults_false(mock_crop_data: CropData) -> None: + """The deposit-all-residue flag defaults to False so ordinary crops are unaffected.""" + assert mock_crop_data.deposit_all_residue_at_harvest is False + + @pytest.mark.parametrize("frac,expect", [(0, False), (0.5, False), (1, True), (1.5, True)]) def test_is_mature_property(mock_crop_data: CropData, frac: float, expect: bool) -> None: """Check that the is_mature property is properly assigning maturity by heat fraction.""" diff --git a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data_factory.py b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data_factory.py index a4df3c4d40..184aa632fd 100644 --- a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data_factory.py +++ b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_data_factory.py @@ -292,6 +292,54 @@ def test_create_crop_data() -> None: assert actual_value == expected_value +def test_create_crop_data_preserves_deposit_all_residue_flag() -> None: + """Test that a crop configuration's deposit_all_residue_at_harvest flag is carried into the created CropData.""" + CropDataFactory._crop_configurations = { + "pasture": CropConfiguration( + name="pasture", + plant_category=PlantCategory.PERENNIAL, + is_nitrogen_fixer=False, + deposit_all_residue_at_harvest=True, + minimum_temperature=0.0, + optimal_temperature=15.0, + potential_heat_units=800.0, + max_leaf_area_index=4.0, + first_heat_fraction_point=0.15, + first_leaf_fraction_point=0.01, + second_heat_fraction_point=0.50, + second_leaf_fraction_point=0.95, + senescent_heat_fraction=0.80, + light_use_efficiency=30.0, + emergence_nitrogen_fraction=0.0560, + half_mature_nitrogen_fraction=0.0210, + mature_nitrogen_fraction=0.0120, + emergence_phosphorus_fraction=0.0099, + half_mature_phosphorus_fraction=0.0022, + mature_phosphorus_fraction=0.0019, + max_root_depth=2000.0, + root_distribution_param_da=137.0, + root_distribution_param_c=-1.144, + optimal_harvest_index=0.85, + minimum_harvest_index=0.37, + dry_matter_percentage=88.331, + lignin_dry_matter_percentage=4.167, + crude_protein_percent_at_harvest=13.28, + non_protein_nitrogen_at_harvest=4.056, + starch_at_harvest=2.217, + adf_at_harvest=35.524, + ndf_at_harvest=58.007, + sugar_at_harvest=15.235, + ash_at_harvest=8.567, + yield_nitrogen_fraction=0.021248, + yield_phosphorus_fraction=0.00281, + ) + } + + actual = CropDataFactory.create_crop_data("pasture") + + assert actual.deposit_all_residue_at_harvest is True + + def test_crop_crop_data_error() -> None: """Test that CropDataFactory raises an error when trying to create an unavailable configuration.""" with pytest.raises(ValueError): diff --git a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_management.py b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_management.py index bce3eed594..6a3ceb7346 100644 --- a/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_management.py +++ b/tests/test_biophysical/test_crop_soil_field/crop_tests/test_crop_management.py @@ -234,6 +234,75 @@ def test_manage_harvest( assert actual is None +@pytest.mark.parametrize( + "harvest_op,expect_kill", + [ + (HarvestOperation.HARVEST_ONLY, False), + (HarvestOperation.HARVEST_KILL, True), + ], +) +def test_manage_harvest_deposit_all_residue( + mocker: MockerFixture, + crop_manager: CropManagement, + mock_time: RufasTime, + harvest_op: HarvestOperation, + expect_kill: bool, +) -> None: + """When a crop is flagged to deposit all residue (e.g. a grazed pasture), a harvest collects no yield, produces no + harvested crop for feed storage, and cuts the crop with a collected fraction of zero so all cut biomass is left in + the field as residue.""" + crop_manager.data.deposit_all_residue_at_harvest = True + field_size = 3.0 + soil_data = SoilData(field_size=field_size) + + mocker.patch.object(crop_manager, "determine_harvest_index") + cut_crop = mocker.patch.object(crop_manager, "cut_crop") + get_crop = mocker.patch.object(crop_manager, "_get_harvested_crop") + kill = mocker.patch.object(crop_manager, "kill", wraps=crop_manager.kill) + record_yield = mocker.patch.object(crop_manager, "_record_yield") + transfer_residue = mocker.patch.object(crop_manager, "_transfer_residue") + + actual = crop_manager.manage_harvest(harvest_op, "pasture_field", field_size, mock_time, soil_data) + + cut_crop.assert_called_once_with(collected_fraction=0.0) + get_crop.assert_not_called() + assert actual is None + + kill.assert_called_once() if expect_kill else kill.assert_not_called() + record_yield.assert_called_once() + transfer_residue.assert_called_once_with(soil_data, expect_kill) + + +def test_manage_harvest_deposit_all_residue_leaves_biomass_in_field( + mocker: MockerFixture, mock_crop_data: CropData, mock_time: RufasTime +) -> None: + """End-to-end check that a harvest of a deposit-all-residue crop removes no yield and instead deposits the cut + biomass into the field's surface soil layer as residue.""" + mock_crop_data.deposit_all_residue_at_harvest = True + mock_crop_data.biomass = 5000.0 + mock_crop_data.above_ground_biomass = 4000.0 + mock_crop_data.root_biomass = 1000.0 + mock_crop_data.leaf_area_index = 3.0 + crop_manager = CropManagement(crop_data=mock_crop_data) + + field_size = 2.0 + soil_data = SoilData(field_size=field_size) + assert soil_data.soil_layers is not None + surface_layer = soil_data.soil_layers[0] + surface_layer.plant_residue = 0.0 + + mocker.patch.object(crop_manager, "_record_yield") + + harvested = crop_manager.manage_harvest( + HarvestOperation.HARVEST_ONLY, "pasture_field", field_size, mock_time, soil_data + ) + + assert harvested is None + assert crop_manager.dry_matter_yield_collected == 0.0 + assert crop_manager.wet_yield_collected == 0.0 + assert surface_layer.plant_residue > 0.0 + + @pytest.mark.parametrize( "efficiency,harvest,override,should_fail", [