Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8762ebb
Adding inputs
matthew7838 Oct 21, 2025
3376f99
Merge branch 'dev' into cs-dailyspread
matthew7838 Nov 6, 2025
5f786e2
Getting sd inputs
matthew7838 Nov 7, 2025
d6c8479
Merge branch 'dev' into cs-dailyspread
matthew7838 Nov 12, 2025
1e04eac
Merge branch 'dev' into cs-dailyspread
matthew7838 Nov 17, 2025
d06fe4a
Merge branch 'dev' into cs-dailyspread
matthew7838 Feb 5, 2026
7956ec0
Merge branch 'dev' into cs-dailyspread
matthew7838 Feb 17, 2026
0af9b2a
Merge remote-tracking branch 'origin/cs-dailyspread' into cs-dailyspread
matthew7838 Feb 17, 2026
af0831b
Daily spread mods pre testing
matthew7838 Feb 17, 2026
96cd068
Merge af0831bbcc76b6f3df746d572ad7537e9b894195 into 1bdb84d720227fd2f…
matthew7838 Feb 17, 2026
55c3617
Apply Black Formatting
github-actions[bot] Feb 17, 2026
b47a9c8
Update badges on README
matthew7838 Feb 17, 2026
5ca25f3
Temporary implementation before holding on for connection clean up
matthew7838 Feb 24, 2026
ad1a33f
Merge remote-tracking branch 'origin/cs-dailyspread' into cs-dailyspread
matthew7838 Feb 24, 2026
1e7ba13
Merge branch 'dev' into cs-dailyspread
matthew7838 Apr 14, 2026
2505ad5
Merge 1e7ba13c9661507f207b060a4f0e7ef65c9780de into 80495247b509a7568…
matthew7838 Apr 14, 2026
5ea8d3a
Apply Black Formatting
github-actions[bot] Apr 14, 2026
5ea461a
Update badges on README
matthew7838 Apr 14, 2026
848ea94
Merge branch 'dev' into cs-dailyspread
matthew7838 Apr 15, 2026
3684b80
Merge 848ea94e5bbb90353215bdc1290fa640c4f18dc7 into 5fa2e6f84c970ce31…
matthew7838 Apr 15, 2026
bf9d0ba
Apply Black Formatting
github-actions[bot] Apr 15, 2026
c0cdcac
Merge remote-tracking branch 'origin/cs-dailyspread' into cs-dailyspread
matthew7838 Apr 15, 2026
54a5826
Merge c0cdcacc5469ab54b4d5c0459020cccc2eb8aea5 into 5fa2e6f84c970ce31…
matthew7838 Apr 15, 2026
3abff9f
Apply Black Formatting
github-actions[bot] Apr 15, 2026
c02174a
Update badges on README
matthew7838 Apr 15, 2026
8b04bc0
Input changes
matthew7838 Apr 15, 2026
66bb267
Merge 8b04bc05ab55b95ac11ad5d82071d24aba5d3bdd into 5fa2e6f84c970ce31…
matthew7838 Apr 15, 2026
8083491
Apply Black Formatting
github-actions[bot] Apr 15, 2026
c825386
Update badges on README
matthew7838 Apr 15, 2026
d2175a0
Updated change log
matthew7838 Apr 15, 2026
f17f277
Merge remote-tracking branch 'origin/cs-dailyspread' into cs-dailyspread
matthew7838 Apr 15, 2026
1d143fe
Fixed cross validation failures
matthew7838 Apr 18, 2026
344024d
Added variables for init
matthew7838 Apr 20, 2026
9b92a86
fix
matthew7838 Apr 20, 2026
ca2c4b3
fix missing field
matthew7838 Apr 20, 2026
765289d
Merge branch 'dev' into cs-dailyspread
matthew7838 Apr 20, 2026
c988fb0
Merge 765289deca71075f3c57500625f3376faa110c0b into a1a86388affaccf66…
matthew7838 Apr 20, 2026
15b61de
Apply Black Formatting
github-actions[bot] Apr 20, 2026
724ff09
Update badges on README
matthew7838 Apr 20, 2026
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
8 changes: 4 additions & 4 deletions 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-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
Expand Down
45 changes: 42 additions & 3 deletions RUFAS/biophysical/field/field/field.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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__,
Expand All @@ -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)

Expand Down
14 changes: 9 additions & 5 deletions RUFAS/biophysical/field/manager/field_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand Down Expand Up @@ -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]:
Expand Down
154 changes: 136 additions & 18 deletions RUFAS/biophysical/manure/manure_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion RUFAS/biophysical/manure/storage/daily_spread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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()
Loading