diff --git a/README.md b/README.md index 2024cc3be2..04973ff2dc 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-1180%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Flake8](https://img.shields.io/badge/Flake8-failed-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Pytest](https://img.shields.io/badge/Pytest-failed-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Coverage](https://img.shields.io/badge/Coverage-%25-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Mypy](https://img.shields.io/badge/Mypy-1197%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/field/field.py b/RUFAS/biophysical/field/field/field.py index dd2ac686a5..6abef50bb8 100644 --- a/RUFAS/biophysical/field/field/field.py +++ b/RUFAS/biophysical/field/field/field.py @@ -1,6 +1,6 @@ import math from math import exp -from typing import Dict, List, Optional, Sequence, TypeVar +from typing import Dict, List, Optional, Sequence, TypeVar, Any from RUFAS.current_day_conditions import CurrentDayConditions from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop @@ -111,6 +111,7 @@ def __init__( fertilizer_events: Optional[List[FertilizerEvent]] = None, fertilizer_mixes: Optional[Dict[str, Dict[str, float]]] = None, manure_events: Optional[List[ManureEvent]] = None, + daily_spread_settings: Optional[dict[str, Any]] = None, ) -> None: # field-wide attributes self.om = OutputManager() @@ -143,6 +144,7 @@ def __init__( self.manure_applicator = ManureApplication(self.soil.data) self.manure_events: list[ManureEvent] = manure_events or [] + self.daily_spread_settings = daily_spread_settings def manage_field( self, @@ -1058,8 +1060,44 @@ def check_manure_application_schedule(self, time: RufasTime) -> list[ManureEvent for event in todays_manure_events: manure_request = self._create_manure_request(event) manure_requests.append(ManureEventNutrientRequest(self.field_data.name, event, manure_request)) + + daily_spread_event = self._create_daily_spread_event(time) + if daily_spread_event is not None: + manure_request = self._create_manure_request(daily_spread_event) + manure_requests.append(ManureEventNutrientRequest(self.field_data.name, daily_spread_event, manure_request)) return manure_requests + def _create_daily_spread_event(self, time: RufasTime) -> ManureEvent | None: + """Creates a daily manure event from daily spread settings, if enabled.""" + if not self.daily_spread_settings: + return None + if not self.daily_spread_settings.get("is_daily_spreading", False): + return None + + manure_type = ManureType(self.daily_spread_settings.get("manure_type", ManureType.SOLID.value)) + manure_supplement_method = ManureSupplementMethod( + self.daily_spread_settings.get( + "supplement_manure_nutrient_deficiencies", + ManureSupplementMethod.NONE.value, + ) + ) + nitrogen_spread_amount = self.daily_spread_settings.get("nitrogen_spread_amount", 0.0) + phosphorus_spread_amount = self.daily_spread_settings.get("phosphorus_spread_amount", 0.0) + nitrogen_cap = self.daily_spread_settings.get("max_nitrogen", nitrogen_spread_amount) + phosphorus_cap = self.daily_spread_settings.get("max_phosphorus", phosphorus_spread_amount) + return ManureEvent( + nitrogen_mass=min(nitrogen_spread_amount, nitrogen_cap), + phosphorus_mass=min(phosphorus_spread_amount, phosphorus_cap), + manure_type=manure_type, + manure_supplement_method=manure_supplement_method, + field_coverage=self.daily_spread_settings.get("coverage_fraction", 1.0), + application_depth=self.daily_spread_settings.get("application_depth", 0.0), + surface_remainder_fraction=self.daily_spread_settings.get("surface_remainder_fraction", 1.0), + year=time.current_calendar_year, + day=time.current_julian_day, + is_daily_spread=True, + ) + def _create_manure_request(self, event: ManureEvent) -> NutrientRequest | None: """ Creates a NutrientRequest object from the attributes of a ManureEvent. @@ -1098,6 +1136,7 @@ def _create_manure_request(self, event: ManureEvent) -> NutrientRequest | None: phosphorus=event.phosphorus_mass, manure_type=event.manure_type, use_supplemental_manure=use_supplemental_manure, + use_daily_spread_source=bool(getattr(event, "is_daily_spread", False)), ) def _check_crop_harvest_schedule( @@ -1276,8 +1315,8 @@ def _record_planting( "year": MeasurementUnits.CALENDAR_YEAR, "day": MeasurementUnits.ORDINAL_DAY, "field_size": MeasurementUnits.HECTARE, - "average_clay_percent": MeasurementUnits.PERCENT, "field_name": MeasurementUnits.UNITLESS, + "average_clay_percent": MeasurementUnits.PERCENT, } info_map = { "class": self.__class__.__name__, @@ -1291,8 +1330,8 @@ def _record_planting( "year": year, "day": day, "field_size": self.field_data.field_size, - "average_clay_percent": self.soil.data.average_clay_percent, "field_name": self.field_data.name, + "average_clay_percent": self.soil.data.average_clay_percent, } self.om.add_variable("crop_planting", value, info_map) diff --git a/RUFAS/biophysical/field/manager/field_manager.py b/RUFAS/biophysical/field/manager/field_manager.py index b94ff5f30e..2391a63a27 100644 --- a/RUFAS/biophysical/field/manager/field_manager.py +++ b/RUFAS/biophysical/field/manager/field_manager.py @@ -178,7 +178,9 @@ def _setup_field(field_name: str, available_crop_configs: list[str]) -> Field: field_configuration_data["fertilizer_management_specification"] ) - manure_events = FieldManager._setup_manure_events(field_configuration_data["manure_management_specification"]) + manure_events, daily_spread_settings = FieldManager._setup_manure_events( + field_configuration_data["manure_management_specification"] + ) tillage_events = FieldManager._setup_tillage_events( field_configuration_data["tillage_management_specification"] @@ -197,6 +199,7 @@ def _setup_field(field_name: str, available_crop_configs: list[str]) -> Field: fertilizer_events=fertilizer_events, fertilizer_mixes=available_fertilizer_mixes, manure_events=manure_events, + daily_spread_settings=daily_spread_settings, ) @staticmethod @@ -321,7 +324,7 @@ def _setup_fertilizer_events( return available_fertilizer_mixes, fertilizer_application_events @staticmethod - def _setup_manure_events(manure_schedule: str) -> list[ManureEvent]: + def _setup_manure_events(manure_schedule: str) -> tuple[list[ManureEvent], dict[str, Any] | None]: """ Sets up a list of manure events from ManureSchedule. @@ -332,8 +335,8 @@ def _setup_manure_events(manure_schedule: str) -> list[ManureEvent]: Returns ------- - list[ManureEvent] - A list of generated manure events. + tuple[list[ManureEvent], dict[str, Any] | None] + A list of generated manure events and optional daily spread settings. """ im = InputManager() @@ -368,7 +371,8 @@ def _setup_manure_events(manure_schedule: str) -> list[ManureEvent]: pattern_repeat=manure_schedule_data["pattern_repeat"], ) manure_events = manure_schedule_instance.generate_manure_events() - return manure_events + daily_spread_settings = manure_schedule_data.get("daily_spread") + return manure_events, daily_spread_settings @staticmethod def _setup_tillage_events(tillage_schedule: str) -> list[TillageEvent]: diff --git a/RUFAS/biophysical/manure/manure_manager.py b/RUFAS/biophysical/manure/manure_manager.py index c511ddbbdb..e8a69bafbf 100644 --- a/RUFAS/biophysical/manure/manure_manager.py +++ b/RUFAS/biophysical/manure/manure_manager.py @@ -147,7 +147,7 @@ def _build_nutrient_pools(self) -> None: Build the pool for aggregated storage type. """ for name, processor in self.all_processors.items(): - if isinstance(processor, Storage): + if isinstance(processor, Storage) and not isinstance(processor, DailySpread): manure_type = STORAGE_CLASS_TO_TYPE.get(type(processor)) nutrients = ManureNutrients( manure_type=manure_type, @@ -856,12 +856,31 @@ def request_nutrients( """ if simulate_animals: - request_result, is_nutrient_request_fulfilled = self._manure_nutrient_manager.handle_nutrient_request( - request - ) + if request.use_daily_spread_source: + daily_spread_storages, _ = self._split_storages_by_daily_spread() + request_result, is_nutrient_request_fulfilled = self._handle_nutrient_request_for_storages( + request=request, storages=daily_spread_storages + ) + if request_result is not None: + self._remove_nutrients_from_storage( + request_result, + request.manure_type, + include_daily_spread=True, + update_nutrient_manager_pool=False, + ) + + else: + request_result, is_nutrient_request_fulfilled = self._manure_nutrient_manager.handle_nutrient_request( + request + ) + if request_result is not None: + self._remove_nutrients_from_storage( + request_result, + request.manure_type, + include_daily_spread=False, + ) + self._record_manure_request_results(request_result, "on_farm_manure", time) - if request_result is not None: - self._remove_nutrients_from_storage(request_result, request.manure_type) if not is_nutrient_request_fulfilled and request.use_supplemental_manure: self._om.add_log( @@ -879,7 +898,13 @@ def request_nutrients( else: return FieldManureSupplier.request_nutrients(request) - def _remove_nutrients_from_storage(self, results: NutrientRequestResults, manure_type: ManureType) -> None: + def _remove_nutrients_from_storage( + self, + results: NutrientRequestResults, + manure_type: ManureType, + include_daily_spread: bool, + update_nutrient_manager_pool: bool = True, + ) -> None: """ Remove nutrients from the storage based on the results of a nutrient request by manure type. @@ -889,7 +914,35 @@ def _remove_nutrients_from_storage(self, results: NutrientRequestResults, manure The results of a nutrient request. See :class:`NutrientsRequestResults` for details. """ - nutrient_pool = self._manure_nutrient_manager.nutrients_by_manure_category[manure_type] + daily_spread_storages, non_daily_storages = self._split_storages_by_daily_spread() + storage_processors: list[Storage] = daily_spread_storages if include_daily_spread else non_daily_storages + nitrogen = 0.0 + phosphorus = 0.0 + potassium = 0.0 + total_manure_mass = 0.0 + dry_matter = 0.0 + for storage in storage_processors: + storage_manure_type = STORAGE_CLASS_TO_TYPE.get(type(storage)) + if storage_manure_type != manure_type: + continue + source_stream = ( + storage.available_for_field_application if isinstance(storage, DailySpread) else storage.stored_manure + ) + nitrogen += source_stream.nitrogen + phosphorus += source_stream.phosphorus + potassium += source_stream.potassium + total_manure_mass += source_stream.mass + dry_matter += source_stream.total_solids + nutrient_pool = ManureNutrients( + manure_type=manure_type, + nitrogen=nitrogen, + phosphorus=phosphorus, + potassium=potassium, + total_manure_mass=total_manure_mass, + dry_matter=dry_matter, + ) + if math.isclose(nutrient_pool.total_manure_mass, 0.0, abs_tol=1e-5): + return is_nitrogen_limiting_nutrient = self._determine_limiting_nutrient( results.nitrogen, nutrient_pool.nitrogen_composition, @@ -915,20 +968,85 @@ def _remove_nutrients_from_storage(self, results: NutrientRequestResults, manure "non_degradable_volatile_solids", "degradable_volatile_solids", "total_solids", - "bedding_non_degradable_volatile_solids", ] - for name, processor in self.all_processors.items(): - if isinstance(processor, Storage): - processor.stored_manure, removal_details = self._compute_stream_after_removal( - stored_manure=processor.stored_manure, - nutrient_removal_proportion=proportion_of_limiting_nutrient_to_remove, - is_nitrogen_limiting_nutrient=is_nitrogen_limiting_nutrient, - non_limiting_fields=non_limiting_fields.copy(), - ) - removal_details["manure_type"] = STORAGE_CLASS_TO_TYPE.get(type(processor)) + for processor in storage_processors: + storage_manure_type = STORAGE_CLASS_TO_TYPE.get(type(processor)) + if storage_manure_type != manure_type: + continue + source_stream = ( + processor.available_for_field_application + if isinstance(processor, DailySpread) + else processor.stored_manure + ) + updated_stream, removal_details = self._compute_stream_after_removal( + stored_manure=source_stream, + nutrient_removal_proportion=proportion_of_limiting_nutrient_to_remove, + is_nitrogen_limiting_nutrient=is_nitrogen_limiting_nutrient, + non_limiting_fields=non_limiting_fields.copy(), + ) + if isinstance(processor, DailySpread): + processor.set_available_for_field_application(updated_stream) + else: + processor.stored_manure = updated_stream + if update_nutrient_manager_pool: + removal_details["manure_type"] = storage_manure_type self._manure_nutrient_manager.remove_nutrients(removal_details) + def _split_storages_by_daily_spread(self) -> tuple[list[Storage], list[Storage]]: + """Split all storages into daily spread and non-daily spread groups.""" + daily_spread_storages = [] + non_daily_storages = [] + for processor in self.all_processors.values(): + if not isinstance(processor, Storage): + continue + if isinstance(processor, DailySpread): + daily_spread_storages.append(processor) + else: + non_daily_storages.append(processor) + return daily_spread_storages, non_daily_storages + + def _handle_nutrient_request_for_storages( + self, request: NutrientRequest, storages: list[Storage] + ) -> tuple[NutrientRequestResults | None, bool]: + """Handle a nutrient request for an explicit set of storages.""" + nitrogen = 0.0 + phosphorus = 0.0 + potassium = 0.0 + total_manure_mass = 0.0 + dry_matter = 0.0 + for storage in storages: + storage_manure_type = STORAGE_CLASS_TO_TYPE.get(type(storage)) + if storage_manure_type != request.manure_type: + continue + source_stream = ( + storage.available_for_field_application if isinstance(storage, DailySpread) else storage.stored_manure + ) + nitrogen += source_stream.nitrogen + phosphorus += source_stream.phosphorus + potassium += source_stream.potassium + total_manure_mass += source_stream.mass + dry_matter += source_stream.total_solids + nutrient_pool = ManureNutrients( + manure_type=request.manure_type, + nitrogen=nitrogen, + phosphorus=phosphorus, + potassium=potassium, + total_manure_mass=total_manure_mass, + dry_matter=dry_matter, + ) + subset_manager = ManureNutrientManager() + subset_manager.reset_nutrient_pools() + subset_manager.add_nutrients(nutrient_pool) + return subset_manager.handle_nutrient_request(request) + + def finalize_daily_spread_exports(self, time: RufasTime) -> None: + """Report and clear remaining daily spread manure available for field application.""" + daily_spread_storages, _ = self._split_storages_by_daily_spread() + for processor in daily_spread_storages: + assert isinstance(processor, DailySpread) + processor.export_and_clear_remaining_available(time) + @staticmethod def _compute_stream_after_removal( stored_manure: ManureStream, diff --git a/RUFAS/biophysical/manure/storage/daily_spread.py b/RUFAS/biophysical/manure/storage/daily_spread.py index e9512f3e01..7fbca7cf69 100644 --- a/RUFAS/biophysical/manure/storage/daily_spread.py +++ b/RUFAS/biophysical/manure/storage/daily_spread.py @@ -22,6 +22,7 @@ def __init__( storage_time_period=storage_time_period, surface_area=surface_area, ) + self.available_for_field_application = ManureStream.make_empty_manure_stream() def receive_manure(self, manure: ManureStream) -> None: """ @@ -52,4 +53,18 @@ def process_manure(self, current_day_conditions: CurrentDayConditions, time: Ruf """ self._report_manure_stream(self._received_manure, "received", time.simulation_day) - return super().process_manure(current_day_conditions, time) + output_streams = super().process_manure(current_day_conditions, time) + self.available_for_field_application = output_streams.get("manure", ManureStream.make_empty_manure_stream()) + self._report_manure_stream( + self.available_for_field_application, "available_for_application", time.simulation_day + ) + return {} + + def set_available_for_field_application(self, stream: ManureStream) -> None: + """Set remaining manure available for daily spread field application.""" + self.available_for_field_application = stream + + def export_and_clear_remaining_available(self, time: RufasTime) -> None: + """Report and clear leftover daily spread manure at end of day.""" + self._report_manure_stream(self.available_for_field_application, "exported_excess", time.simulation_day) + self.available_for_field_application = ManureStream.make_empty_manure_stream() diff --git a/RUFAS/data_structures/events.py b/RUFAS/data_structures/events.py index 98e4783b85..9953010ad3 100644 --- a/RUFAS/data_structures/events.py +++ b/RUFAS/data_structures/events.py @@ -231,6 +231,8 @@ class ManureEvent(BaseFieldManagementEvent): Depth that manure is injected into the soil at (mm). surface_remainder_fraction : float Fraction of manure applied that remains on the soil surface (unitless). + is_daily_spread : bool + Whether this manure event is from daily spread handling. """ @@ -245,6 +247,7 @@ def __init__( surface_remainder_fraction: float, year: int, day: int, + is_daily_spread: bool = False, ): super().__init__(year=year, day=day) self.nitrogen_mass = nitrogen_mass @@ -254,6 +257,7 @@ def __init__( self.field_coverage = field_coverage self.application_depth = application_depth self.surface_remainder_fraction = surface_remainder_fraction + self.is_daily_spread = is_daily_spread def __eq__(self, other) -> bool: """Overrides the equality operator for ManureEvent objects.""" @@ -267,6 +271,7 @@ def __eq__(self, other) -> bool: and other.application_depth == self.application_depth and other.surface_remainder_fraction == self.surface_remainder_fraction and other.manure_supplement_method == self.manure_supplement_method + and other.is_daily_spread == self.is_daily_spread ) return False @@ -282,6 +287,7 @@ def __hash__(self) -> int: self.field_coverage, self.application_depth, self.surface_remainder_fraction, + self.is_daily_spread, ) ) diff --git a/RUFAS/data_structures/manure_to_crop_soil_connection.py b/RUFAS/data_structures/manure_to_crop_soil_connection.py index 73df4c5d0d..4f803a3d2f 100644 --- a/RUFAS/data_structures/manure_to_crop_soil_connection.py +++ b/RUFAS/data_structures/manure_to_crop_soil_connection.py @@ -21,6 +21,9 @@ class NutrientRequest: use_supplemental_manure: bool """Whether to use supplemental manure if the request cannot be fulfilled by on-farm manure.""" + use_daily_spread_source: bool = False + """Whether this request should first use DailySpread processors.""" + def __post_init__(self) -> None: """ Validate the dataclass fields. diff --git a/input/data/manure_schedule/daily_spread_config.json b/input/data/manure_schedule/daily_spread_config.json new file mode 100644 index 0000000000..1e20065b51 --- /dev/null +++ b/input/data/manure_schedule/daily_spread_config.json @@ -0,0 +1,8 @@ +{ + "use_daily_spread": false, + "max_nitrogen_masses": 500, + "max_phosphorus_masses": 500, + "max_potassium_masses": 500, + "application_depths": 30, + "surface_remainder_fractions": 0.20 +} \ No newline at end of file diff --git a/input/data/manure_schedule/example_manure_schedule_file.json b/input/data/manure_schedule/example_manure_schedule_file.json new file mode 100644 index 0000000000..ab7a95aa39 --- /dev/null +++ b/input/data/manure_schedule/example_manure_schedule_file.json @@ -0,0 +1,32 @@ +{ + "years": [], + "days": [], + "nitrogen_masses": [], + "phosphorus_masses": [], + "potassium_masses": [], + "coverage_fractions": [], + "application_depths": [], + "surface_remainder_fractions": [], + "manure_types": [], + "supplement_manure_nutrient_deficiencies": [], + "pattern_repeat": 0, + "pattern_skip": 0, + + "daily_spread": { + "is_daily_spreading": true, + + "nitrogen_spread_amount": 8.0, + "phosphorus_spread_amount": 2.5, + "potassium_spread_amount": 3.0, + + "max_nitrogen": 8.0, + "max_phosphorus": 2.5, + "max_potassium": 3.0, + + "coverage_fraction": 1.0, + "application_depth": 0.0, + "surface_remainder_fraction": 1.0, + "manure_type": "solid", + "supplement_manure_nutrient_deficiencies": "none" + } +} diff --git a/input/data/manure_schedule/example_sm_corn_alf_manure_schedule.json b/input/data/manure_schedule/example_sm_corn_alf_manure_schedule.json index ac3aee742d..3b1d0a8e2b 100644 --- a/input/data/manure_schedule/example_sm_corn_alf_manure_schedule.json +++ b/input/data/manure_schedule/example_sm_corn_alf_manure_schedule.json @@ -55,5 +55,19 @@ ], "supplement_manure_nutrient_deficiencies": ["synthetic fertilizer and manure", "manure", "manure", "manure"], "pattern_repeat": 1, - "pattern_skip": 4 -} \ No newline at end of file + "pattern_skip": 4, + "daily_spread": { + "is_daily_spreading": false, + "nitrogen_spread_amount": 0.0, + "phosphorus_spread_amount": 0.0, + "potassium_spread_amount": 0.0, + "max_nitrogen": 0.0, + "max_phosphorus": 0.0, + "max_potassium": 0.0, + "coverage_fraction": 1.0, + "application_depth": 0.0, + "surface_remainder_fraction": 1.0, + "manure_type": "solid", + "supplement_manure_nutrient_deficiencies": "none" + } +} diff --git a/input/metadata/properties/default.json b/input/metadata/properties/default.json index 2d7c0b8443..13874bade3 100644 --- a/input/metadata/properties/default.json +++ b/input/metadata/properties/default.json @@ -2985,6 +2985,83 @@ "pattern": "^(manure|synthetic fertilizer|none|synthetic fertilizer and manure)$" } }, + "daily_spread": { + "type": "object", + "description": "Configuration for daily spread manure application sourced from DailySpread processors.", + "is_daily_spreading": { + "type": "bool", + "description": "Whether this field uses daily spread manure applications.", + "default": false + }, + "nitrogen_spread_amount": { + "type": "number", + "description": "Target nitrogen amount for this field's daily spread request. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "phosphorus_spread_amount": { + "type": "number", + "description": "Target phosphorus amount for this field's daily spread request. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "potassium_spread_amount": { + "type": "number", + "description": "Target potassium amount for this field's daily spread request. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "max_nitrogen": { + "type": "number", + "description": "Cap on nitrogen this field can receive per day from daily spread manure. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "max_phosphorus": { + "type": "number", + "description": "Cap on phosphorus this field can receive per day from daily spread manure. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "max_potassium": { + "type": "number", + "description": "Cap on potassium this field can receive per day from daily spread manure. Units: kg.", + "minimum": 0.0, + "default": 0.0 + }, + "coverage_fraction": { + "type": "number", + "description": "Fraction of field area covered by each daily spread manure application.", + "minimum": 0.01, + "maximum": 1.0, + "default": 1.0 + }, + "application_depth": { + "type": "number", + "description": "Depth of daily spread manure application. Units: mm.", + "minimum": 0.0, + "default": 0.0 + }, + "surface_remainder_fraction": { + "type": "number", + "description": "Fraction of daily spread manure remaining on soil surface.", + "minimum": 0.0, + "maximum": 1.0, + "default": 1.0 + }, + "manure_type": { + "type": "string", + "description": "Type of manure requested for daily spread applications.", + "pattern": "^(liquid|solid)$", + "default": "solid" + }, + "supplement_manure_nutrient_deficiencies": { + "type": "string", + "description": "Whether daily spread nutrient shortfalls can be supplemented.", + "pattern": "^(manure|synthetic fertilizer|none|synthetic fertilizer and manure)$", + "default": "none" + } + }, "pattern_repeat": { "type": "number", "description": "Number of times that this manure application schedule should be repeated.", @@ -6851,4 +6928,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/test_biophysical/test_crop_soil_field/field_tests/test_field.py b/tests/test_biophysical/test_crop_soil_field/field_tests/test_field.py index bb8bd26f9a..98665537e4 100644 --- a/tests/test_biophysical/test_crop_soil_field/field_tests/test_field.py +++ b/tests/test_biophysical/test_crop_soil_field/field_tests/test_field.py @@ -383,6 +383,77 @@ def test_check_manure_application_schedule_integration() -> None: assert manure_requests[0].nutrient_request.manure_type == ManureType.LIQUID +def test_create_daily_spread_event_uses_spread_amounts_and_caps() -> None: + """Daily spread event should request spread amounts while honoring max caps.""" + field = Field( + daily_spread_settings={ + "is_daily_spreading": True, + "manure_type": "solid", + "supplement_manure_nutrient_deficiencies": "none", + "nitrogen_spread_amount": 12.0, + "phosphorus_spread_amount": 4.0, + "potassium_spread_amount": 2.0, + "max_nitrogen": 10.0, + "max_phosphorus": 5.0, + "max_potassium": 3.0, + "coverage_fraction": 1.0, + "application_depth": 0.0, + "surface_remainder_fraction": 1.0, + } + ) + mocked_time = MagicMock(RufasTime) + mocked_time.current_calendar_year = 2025 + mocked_time.current_julian_day = 200 + + event = field._create_daily_spread_event(mocked_time) + + assert event is not None + assert event.nitrogen_mass == 10.0 + assert event.phosphorus_mass == 4.0 + assert event.manure_type == ManureType.SOLID + assert event.is_daily_spread is True + assert event.year == 2025 + assert event.day == 200 + + +def test_check_manure_application_schedule_daily_spread_request_uses_amounts() -> None: + """Daily spread request should use spread amounts and mark request source as daily spread.""" + field = Field( + manure_events=[], + daily_spread_settings={ + "is_daily_spreading": True, + "manure_type": "liquid", + "supplement_manure_nutrient_deficiencies": "none", + "nitrogen_spread_amount": 6.0, + "phosphorus_spread_amount": 3.0, + "potassium_spread_amount": 1.0, + "max_nitrogen": 8.0, + "max_phosphorus": 2.0, + "max_potassium": 2.0, + "coverage_fraction": 1.0, + "application_depth": 0.0, + "surface_remainder_fraction": 1.0, + }, + ) + field.field_data.name = "field_daily" + + mocked_time = MagicMock(RufasTime) + mocked_time.current_calendar_year = 2026 + mocked_time.current_julian_day = 10 + + manure_requests = field.check_manure_application_schedule(mocked_time) + + assert len(manure_requests) == 1 + request = manure_requests[0] + assert request.field_name == "field_daily" + assert request.event.is_daily_spread is True + assert request.event.nitrogen_mass == 6.0 + assert request.event.phosphorus_mass == 2.0 + assert request.nutrient_request.nitrogen == 6.0 + assert request.nutrient_request.phosphorus == 2.0 + assert request.nutrient_request.use_daily_spread_source is True + + @pytest.mark.parametrize( "nitrogen_mass, phosphorus_mass, manure_type, expected_request, expected_log", [ @@ -434,6 +505,7 @@ def test_create_manure_request( assert nutrient_request.nitrogen == expected_request.nitrogen assert nutrient_request.phosphorus == expected_request.phosphorus assert nutrient_request.manure_type == expected_request.manure_type + assert nutrient_request.use_daily_spread_source is False field.om.add_warning.assert_not_called() else: diff --git a/tests/test_biophysical/test_crop_soil_field/manager_tests/test_field_manager.py b/tests/test_biophysical/test_crop_soil_field/manager_tests/test_field_manager.py index be32f0cf0a..13ea90e8bc 100644 --- a/tests/test_biophysical/test_crop_soil_field/manager_tests/test_field_manager.py +++ b/tests/test_biophysical/test_crop_soil_field/manager_tests/test_field_manager.py @@ -828,7 +828,7 @@ def test_setup_manure_schedule( """Tests that ManureSchedules are correctly initialized with data from the InputManager.""" mock_get_data = mocker.patch.object(mock_input_manager, "get_data", return_value=manure_schedule_data) expected_manure_events = expected_manure_schedule.generate_manure_events() - actual_manure_events = FieldManager._setup_manure_events("test_manure_schedule") + actual_manure_events, actual_daily_spread_settings = FieldManager._setup_manure_events("test_manure_schedule") assert actual_manure_events == expected_manure_events mock_get_data.assert_called_once_with("test_manure_schedule") @@ -1967,7 +1967,7 @@ def test_setup_field( ) mock_setup_manure_events = mocker.patch( "RUFAS.biophysical.field.manager.field_manager.FieldManager._setup_manure_events", - return_value=mock_manure_events, + return_value=(mock_manure_events, {"is_daily_spreading": True}), ) mock_setup_tillage_events = mocker.patch( "RUFAS.biophysical.field.manager.field_manager.FieldManager._setup_tillage_events", @@ -1998,6 +1998,7 @@ def test_setup_field( "100_0_0": {"N": 1.0, "P": 0.0, "K": 0.0, "ammonium_fraction": 0.0}, "26_4_24": {"N": 0.26, "P": 0.04, "K": 0.24, "ammonium_fraction": 0.0}, } + assert new_field.daily_spread_settings == {"is_daily_spreading": True} mock_get_data.assert_called_once_with(field_name) mock_setup_fertilizer_events.assert_called_once_with(field_config.get("fertilizer_management_specification")) diff --git a/tests/test_biophysical/test_manure/test_manure_manager/test_manure_manager.py b/tests/test_biophysical/test_manure/test_manure_manager/test_manure_manager.py index 041987a735..dca6e831e2 100644 --- a/tests/test_biophysical/test_manure/test_manure_manager/test_manure_manager.py +++ b/tests/test_biophysical/test_manure/test_manure_manager/test_manure_manager.py @@ -12,6 +12,7 @@ from RUFAS.biophysical.manure.processor import Processor from RUFAS.biophysical.manure.separator.separator import Separator from RUFAS.biophysical.manure.storage.composting import Composting +from RUFAS.biophysical.manure.storage.daily_spread import DailySpread from RUFAS.biophysical.manure.storage.storage import Storage from RUFAS.biophysical.manure.storage.storage_cover import StorageCover from RUFAS.current_day_conditions import CurrentDayConditions @@ -84,7 +85,7 @@ def test_init( "RUFAS.biophysical.manure.manure_manager.ManureManager._populate_adjacency_matrix" ) - ManureManager(0.5, 0.5, 15) + ManureManager() assert mock_get_data.call_args_list == [call("manure_management"), call("manure_processor_connection")] mock_get_processor_configs_by_name.assert_called_once_with(manure_management_input_json) @@ -92,7 +93,7 @@ def test_init( processor_connections_input_json, expected_processor_definitions_by_name ) mock_create_all_processors.assert_called_once_with( - expected_processor_connections_by_name, expected_processor_definitions_by_name, 0.5, 0.5, 15 + expected_processor_connections_by_name, expected_processor_definitions_by_name ) mock_populate_adjacency_matrix.assert_called_once_with(expected_processor_connections_by_name) @@ -380,7 +381,7 @@ def test_create_all_processors( ) manure_manager._create_all_processors( - expected_processor_connections_by_name, expected_processor_definitions_by_name, 0.5, 0.5, 0.5 + expected_processor_connections_by_name, expected_processor_definitions_by_name ) assert mock_separator_init.call_count == 2 @@ -911,7 +912,7 @@ def test_request_nutrients( mock_time.current_calendar_year = 2025 mocker.patch("RUFAS.biophysical.manure.manure_manager.ManureManager.__init__", return_value=None) mock_add_log = mocker.patch.object(OutputManager, "add_log") - manure_manager = ManureManager(0.6, 0.6, 0.6) + manure_manager = ManureManager() manure_manager._manure_nutrient_manager = ManureNutrientManager() manure_manager._om = OutputManager() @@ -919,6 +920,7 @@ def test_request_nutrients( mock_nutrient_request.use_supplemental_manure = use_supplemental_manure mock_nutrient_request.manure_type = ManureType.LIQUID + mock_nutrient_request.use_daily_spread_source = False request_result = ( None @@ -975,11 +977,30 @@ def test_remove_nutrients_from_storage( mock_compute = mocker.patch.object( ManureManager, "_compute_stream_after_removal", return_value=(MagicMock(ManureStream), {"nitrogen": 50}) ) + mock_split = mocker.patch.object(manure_manager, "_split_storages_by_daily_spread") composting = MagicMock(Composting) - composting.stored_manure = MagicMock(ManureStream) - manure_manager.all_processors = {"non_storage": MagicMock(Digester), "storage": composting} + composting.stored_manure = ManureStream( + water=100.0, + ammoniacal_nitrogen=10.0, + nitrogen=100.0, + phosphorus=200.0, + potassium=20.0, + ash=5.0, + non_degradable_volatile_solids=1.0, + degradable_volatile_solids=2.0, + total_solids=120.0, + volume=1.0, + methane_production_potential=0.24, + pen_manure_data=None, + ) + STORAGE_CLASS_TO_TYPE[type(composting)] = ManureType.LIQUID + mock_split.return_value = ([], [composting]) - manure_manager._remove_nutrients_from_storage(NutrientRequestResults(nitrogen=10, phosphorus=20), ManureType.LIQUID) + manure_manager._remove_nutrients_from_storage( + NutrientRequestResults(nitrogen=10, phosphorus=20), + ManureType.LIQUID, + include_daily_spread=False, + ) mock_determine_limiting_nutrient.assert_called_once() if is_nitrogen_limiting_nutrient: @@ -987,7 +1008,47 @@ def test_remove_nutrients_from_storage( else: mock_proportion.assert_called_once_with(20, 200) mock_compute.assert_called_once() - mock_remove.assert_called_once_with({"nitrogen": 50, "manure_type": None}) + mock_remove.assert_called_once_with({"nitrogen": 50, "manure_type": ManureType.LIQUID}) + + +def test_request_nutrients_daily_spread_with_supplement(mocker: MockerFixture) -> None: + """Daily spread requests should pull DailySpread first, then use shared supplemental shortfall logic.""" + mocker.patch("RUFAS.biophysical.manure.manure_manager.ManureManager.__init__", return_value=None) + manure_manager = ManureManager() + manure_manager._manure_nutrient_manager = ManureNutrientManager() + manure_manager._om = OutputManager() + + request = NutrientRequest( + nitrogen=10.0, + phosphorus=5.0, + manure_type=ManureType.SOLID, + use_supplemental_manure=True, + use_daily_spread_source=True, + ) + daily_result = NutrientRequestResults(nitrogen=3.0, phosphorus=2.0, total_manure_mass=10.0) + off_farm_result = NutrientRequestResults(nitrogen=7.0, phosphorus=3.0, total_manure_mass=20.0) + + mock_split = mocker.patch.object( + manure_manager, "_split_storages_by_daily_spread", return_value=([MagicMock(DailySpread)], []) + ) + mock_daily_request = mocker.patch.object( + manure_manager, "_handle_nutrient_request_for_storages", return_value=(daily_result, False) + ) + mock_remove = mocker.patch.object(manure_manager, "_remove_nutrients_from_storage") + mock_record = mocker.patch.object(manure_manager, "_record_manure_request_results") + mocker.patch.object(manure_manager, "_calculate_supplemental_manure_needed", return_value="daily_shortfall") + mock_off_farm = mocker.patch.object(FieldManureSupplier, "request_nutrients", return_value=off_farm_result) + + actual = manure_manager.request_nutrients(request, True, MagicMock(spec=RufasTime)) + + assert actual == daily_result + off_farm_result + mock_split.assert_called_once_with() + mock_daily_request.assert_called_once() + assert mock_remove.call_args_list == [ + call(daily_result, ManureType.SOLID, include_daily_spread=True, update_nutrient_manager_pool=False), + ] + mock_record.assert_called_once() + mock_off_farm.assert_called_once_with("daily_shortfall") @pytest.mark.parametrize( @@ -1000,16 +1061,16 @@ def test_remove_nutrients_from_storage( ], ) def test_compute_stream_after_removal_with_real_manure_stream( - init_n: float, - init_p: float, - limiting_flag: bool, - available_limiting: float, - removal_prop: float, - exp_remain_n: float, - exp_remain_p: float, - exp_removed_n: float, - exp_removed_p: float, -) -> None: + init_n, + init_p, + limiting_flag, + available_limiting, + removal_prop, + exp_remain_n, + exp_remain_p, + exp_removed_n, + exp_removed_p, +): """ Verify compute_stream_after_removal() correctly updates only nitrogen and phosphorus on a real ManureStream, and leaves all other fields unchanged. @@ -1028,7 +1089,6 @@ def test_compute_stream_after_removal_with_real_manure_stream( volume=0.0, methane_production_potential=0.24, pen_manure_data=None, - bedding_non_degradable_volatile_solids=0.0, ) non_lim_fields: list[str] = [] @@ -1051,11 +1111,10 @@ def test_compute_stream_after_removal_with_real_manure_stream( "ammoniacal_nitrogen", "potassium", "ash", - "degradable_volatile_solids", "non_degradable_volatile_solids", + "degradable_volatile_solids", "total_solids", "volume", - "bedding_non_degradable_volatile_solids", ]: assert getattr(new_stream, field_name) == 0.0 @@ -1075,9 +1134,7 @@ def test_compute_stream_after_removal_with_real_manure_stream( (1.0, 50.0, 50.0), ], ) -def test_determine_non_limiting_nutrient_removal_amount( - portion: float, non_limiting: float, expected_removed: float -) -> None: +def test_determine_non_limiting_nutrient_removal_amount(portion: float, non_limiting: float, expected_removed: float): removed = ManureManager._determine_non_limiting_nutrient_removal_amount( limiting_nutrient_proportion_to_be_removed=portion, non_limiting_nutrients_amount=non_limiting, @@ -1098,7 +1155,7 @@ def test_determine_limiting_nutrient_with_patched_scaling( n_mass, p_mass, expected_is_nitrogen_limiting, -) -> None: +): seq = [n_mass, p_mass] mocker.patch.object( ManureNutrientManager, "calculate_projected_manure_mass", side_effect=lambda requested, fraction: seq.pop(0) @@ -1123,9 +1180,9 @@ def test_determine_limiting_nutrient_with_patched_scaling( ], ) def test_determine_limiting_nutrient_proportion_to_be_removed( - requested_mass: float, - available: float, - expected_prop: float, + requested_mass, + available, + expected_prop, ): prop = ManureManager._determine_nutrient_proportion_to_be_removed( limiting_nutrient_requested_mass=requested_mass, @@ -1190,9 +1247,9 @@ def test_determine_limiting_nutrient_proportion_to_be_removed( ) def test_record_manure_request_results_parametrized( mocker: MockerFixture, - manure_request_results: MagicMock, - expected_request_result_values: dict[str, float], - expected_log_called: bool, + manure_request_results, + expected_request_result_values, + expected_log_called, ) -> None: """ Parametrized unit test for the _record_manure_request_results method of the ManureManager class. @@ -1204,7 +1261,7 @@ def test_record_manure_request_results_parametrized( mock_time.current_calendar_year = 2025 mocker.patch("RUFAS.biophysical.manure.manure_manager.ManureManager.__init__", return_value=None) - manure_manager = ManureManager(0.5, 0.5, 0.5) + manure_manager = ManureManager() mock_output_manager = mocker.MagicMock() manure_manager._om = mock_output_manager @@ -1352,7 +1409,7 @@ def test_calculate_supplemental_manure_needed( """ # Arrange mocker.patch("RUFAS.biophysical.manure.manure_manager.ManureManager.__init__", return_value=None) - manure_manager = ManureManager(0.6, 0.6, 0.6) + manure_manager = ManureManager() # Act actual_result = manure_manager._calculate_supplemental_manure_needed(on_farm_manure, nutrient_request) diff --git a/tests/test_biophysical/test_manure/test_storage/test_daily_spread.py b/tests/test_biophysical/test_manure/test_storage/test_daily_spread.py index 647ca5856a..156a82624f 100644 --- a/tests/test_biophysical/test_manure/test_storage/test_daily_spread.py +++ b/tests/test_biophysical/test_manure/test_storage/test_daily_spread.py @@ -35,7 +35,6 @@ def received_manure() -> ManureStream: volume=10.12, methane_production_potential=0.24, pen_manure_data=None, - bedding_non_degradable_volatile_solids=10 ) @@ -67,12 +66,15 @@ def test_process_manure( mock_time.simulation_day = 50 daily_spread_instance._received_manure = received_manure mock_report = mocker.patch.object(Processor, "_report_manure_stream") - mock_process = mocker.patch.object(Storage, "process_manure") + available_stream = received_manure + mock_process = mocker.patch.object(Storage, "process_manure", return_value={"manure": available_stream}) daily_spread_instance.process_manure(mock_conditions, mock_time) - mock_report.assert_called_once_with(received_manure, "received", 50) + mock_report.assert_any_call(received_manure, "received", 50) + mock_report.assert_any_call(available_stream, "available_for_application", 50) mock_process.assert_called_once_with(mock_conditions, mock_time) + assert daily_spread_instance.available_for_field_application == available_stream def test_receive_manure(received_manure: ManureStream, daily_spread_instance: DailySpread) -> None: