Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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-1136%20errors-red)](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)


# RuFaS: Ruminant Farm Systems
Expand Down
7 changes: 4 additions & 3 deletions RUFAS/biophysical/field/crop/crop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions RUFAS/biophysical/field/crop/crop_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion RUFAS/biophysical/field/crop/crop_data_factory.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
14 changes: 9 additions & 5 deletions RUFAS/biophysical/field/crop/crop_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions RUFAS/biophysical/field/field/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions RUFAS/input/metadata/properties/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Expand Down
1 change: 1 addition & 0 deletions changelog_WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,4 @@ This **WIP Changelog** records development changes in progress and not yet inclu
- [2972](https://github.com/RuminantFarmSystems/RuFaS/pull/2972) - [minor change] [OutputManager] [NoInputChange] [NoOutputChange] Adds an `is_daily_variable` info_map flag so OutputManager can distinguish variables reported daily from those reported on another cadence, and renames `_variables_usage_counter` to `_filtered_variable_key_counter` to clarify it counts filter selections rather than reports.
- [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.
38 changes: 38 additions & 0 deletions input/data/crop_configurations/default_crop_configs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down