Skip to content
Draft
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-1135%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml)
[![Mypy](https://img.shields.io/badge/Mypy-1139%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml)


# RuFaS: Ruminant Farm Systems
Expand Down
34 changes: 34 additions & 0 deletions RUFAS/biophysical/feed_storage/feed_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,40 @@ def translate_crop_config_name_to_rufas_id(
next_harvest_dates_rufas_ids[self.crop_to_rufas_id[crop_config]] = harvest_date
return next_harvest_dates_rufas_ids

def stock_initial_storage_contents(
self, initial_contents_by_storage_name: dict[str, dict[str, float]], time: RufasTime
) -> None:
"""
Stocks storages with the initial contents specified for them, if any.

Parameters
----------
initial_contents_by_storage_name : dict[str, dict[str, float]]
The dry matter mass and composition each storage holds when the simulation starts, keyed by storage
instance name. Storages that are not named start empty.
time : RufasTime
RufasTime instance containing the current time of the simulation. This should be called at the start of
the simulation, before any daily routines run.

"""
info_map = {
"class": self.__class__.__name__,
"function": self.stock_initial_storage_contents.__name__,
}
storage_time = time.current_date.date()
for storage_name, initial_contents in initial_contents_by_storage_name.items():
if storage_name not in self.active_storages:
self._om.add_warning(
"Unknown storage in initial feed storage contents",
f"Storage '{storage_name}' has initial contents specified, but no active storage with that name "
"exists. These contents will not be stocked.",
info_map,
)
continue
self.active_storages[storage_name].stock_initial_contents(
initial_contents, storage_time, time.simulation_day
)

def receive_crop(
self,
harvested_crop: HarvestedCrop,
Expand Down
38 changes: 38 additions & 0 deletions RUFAS/biophysical/feed_storage/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,44 @@ def receive_crop(self, crop: HarvestedCrop, simulation_day: int) -> None:
crop.remove_dry_matter_mass(dry_matter_to_remove)
self._record_stored_crops(simulation_day + initial_degradation_day_offset)

def stock_initial_contents(
self, initial_contents: dict[str, float], storage_time: date, simulation_day: int
) -> None:
"""
Stocks this storage with the specified initial contents.

Parameters
----------
initial_contents : dict[str, float]
The dry matter mass and composition of the crop this storage holds when the simulation starts, keyed by
the ``HarvestedCrop`` field names.
storage_time : date
The date the initial contents are considered to have been stored. This should be the start date of the
simulation.
simulation_day : int
The current simulation day, used for record keeping.

Notes
-----
Initial contents let a storage begin the simulation already holding a crop, which is how the feed module is
given something to manage when there is no crop and soil module to harvest anything. The mass and composition
of the contents are stated in the initial feed storage contents input because there is nothing to derive them
from without harvests.

The contents are received through ``receive_crop``, so capacity checks and arrival losses apply to them the
same way they would to a harvested crop arriving on the start date.

"""
composition: dict[str, Any] = dict(initial_contents)
crop = HarvestedCrop(
config_name=self.crop_name,
field_name=self.field_names[0] if self.field_names else self.storage_name,
harvest_time=storage_time,
storage_time=storage_time,
**composition,
)
self.receive_crop(crop, simulation_day)

def process_degradations(self, weather: Weather, time: RufasTime) -> None:
"""
Processes the degradations and losses of nutrients and dry matter in the stored crops.
Expand Down
85 changes: 83 additions & 2 deletions RUFAS/input/metadata/properties/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"type": "string",
"description": "The type of daily simulation to run. Will determine daily simulation function in SimulationEngine.",
"default": "full_farm",
"pattern": "^(full_farm|field_and_feed|field_only|animals_only)$"
"pattern": "^(full_farm|field_and_feed|field_only|animals_only|feed_only)$"
},
"nutrient_standard": {
"type": "string",
Expand Down Expand Up @@ -6900,5 +6900,86 @@
"type": "number"
}
}
},
"initial_feed_storage_contents_properties": {
"data_collection_app_compatible": false,
"initial_contents": {
"type": "array",
"description": "The contents that feed storages hold when the simulation starts. Storages that are not named start empty. Stated directly because a simulation without the crop and soil module has no harvests to derive contents from.",
"minimum_length": 0,
"properties": {
"type": "object",
"description": "The dry matter mass and composition of the crop one storage holds when the simulation starts.",
"storage_name": {
"type": "string",
"description": "Name of the feed storage instance that holds these contents. Must match a name from the feed storage configurations."
},
"dry_matter_mass": {
"type": "number",
"description": "Mass of dry matter initially held in the storage (kg).",
"minimum": 0.0
},
"dry_matter_percentage": {
"type": "number",
"description": "Percent of the initially held mass that is not water.",
"minimum": 0.0,
"maximum": 100.0
},
"dry_matter_digestibility": {
"type": "number",
"description": "Percent of the initially held mass that is digestible.",
"minimum": 0.0,
"maximum": 100.0
},
"crude_protein_percent": {
"type": "number",
"description": "Percent of the initially held mass that is dietary crude protein.",
"minimum": 0.0,
"maximum": 100.0
},
"non_protein_nitrogen": {
"type": "number",
"description": "Percent of the initially held mass that is non-protein nitrogen.",
"minimum": 0.0,
"maximum": 100.0
},
"starch": {
"type": "number",
"description": "Percent of the initially held mass that is starch.",
"minimum": 0.0,
"maximum": 100.0
},
"adf": {
"type": "number",
"description": "Percent of the initially held mass that is acid detergent fiber.",
"minimum": 0.0,
"maximum": 100.0
},
"ndf": {
"type": "number",
"description": "Percent of the initially held mass that is neutral detergent fiber.",
"minimum": 0.0,
"maximum": 100.0
},
"lignin": {
"type": "number",
"description": "Percent of the initially held mass that is lignin.",
"minimum": 0.0,
"maximum": 100.0
},
"sugar": {
"type": "number",
"description": "Percent of the initially held mass that is labile carbohydrate.",
"minimum": 0.0,
"maximum": 100.0
},
"ash": {
"type": "number",
"description": "Percent of the initially held mass that is ash.",
"minimum": 0.0,
"maximum": 100.0
}
}
}
}
}
}
86 changes: 85 additions & 1 deletion RUFAS/simulation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class SimulationType(Enum):
FIELD_AND_FEED = "field_and_feed"
FIELD_ONLY = "field_only"
ANIMALS_ONLY = "animals_only"
FEED_ONLY = "feed_only"

@property
def simulate_animals(self) -> bool:
Expand Down Expand Up @@ -95,7 +96,7 @@ def _fields_simulation_types(cls) -> set["SimulationType"]:
@classmethod
def _feed_simulation_types(cls) -> set["SimulationType"]:
"""Return the set of simulation types that simulate feed storage and management."""
return {cls.FULL_FARM, cls.FIELD_AND_FEED}
return {cls.FULL_FARM, cls.FIELD_AND_FEED, cls.FEED_ONLY}

@classmethod
def get_simulation_type(cls, simulation_type: str) -> "SimulationType":
Expand Down Expand Up @@ -196,6 +197,7 @@ def __init__(self, simulation_type: SimulationType) -> None:
SimulationType.FIELD_AND_FEED: self._execute_field_and_feed_daily_simulation,
SimulationType.FIELD_ONLY: self._execute_field_only_simulation,
SimulationType.ANIMALS_ONLY: self._execute_animals_only_daily_simulation,
SimulationType.FEED_ONLY: self._execute_feed_only_daily_simulation,
}

self._setup_simulation_modules()
Expand Down Expand Up @@ -232,6 +234,7 @@ def _setup_simulation_modules(self) -> None:
feed_storage_configs,
feed_storage_instances,
)
self.feed_manager.stock_initial_storage_contents(self._gather_initial_feed_storage_contents(), self.time)
feed_manager_available_feed_ids = [feed.rufas_id for feed in self.available_feeds]
self.emissions_estimator.check_available_purchased_feed_data(feed_manager_available_feed_ids)
max_daily_feed_recalculations_per_year: int = feeds_config["ration_formulation_parameters"][
Expand Down Expand Up @@ -263,6 +266,51 @@ def _setup_simulation_modules(self) -> None:
self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude
)

def _gather_initial_feed_storage_contents(self) -> dict[str, dict[str, float]]:
"""
Gathers the initial contents that feed storages hold when the simulation starts.

All initial feed storage contents input files are combined. Logs a warning if fields are not simulated and no
initial contents input files are found, since without harvests the feed storages would then stay empty for the
whole simulation.

Returns
-------
dict[str, dict[str, float]]
The dry matter mass and composition each storage holds when the simulation starts, keyed by storage
instance name. Empty if no initial contents inputs are provided.
"""
info_map = {
"class": SimulationEngine.__name__,
"function": SimulationEngine._gather_initial_feed_storage_contents.__name__,
}
initial_contents_names: list[str] = self.im.get_data_keys_by_properties(
"initial_feed_storage_contents_properties"
)
if not initial_contents_names and not self.simulate_fields:
self.om.add_warning(
"No initial feed storage contents input files.",
"All feed storages will start the simulation empty, and there are no fields to harvest crops from.",
info_map,
)

initial_contents_by_storage_name: dict[str, dict[str, float]] = {}
for initial_contents_name in initial_contents_names:
initial_contents_data: dict[str, list[dict[str, Any]]] = self.im.get_data(initial_contents_name)
for storage_contents in initial_contents_data["initial_contents"]:
storage_contents = dict(storage_contents)
storage_name = str(storage_contents.pop("storage_name"))
if storage_name in initial_contents_by_storage_name:
self.om.add_warning(
"Duplicate storage in initial feed storage contents",
f"Initial contents for storage '{storage_name}' are specified more than once. Only the first "
"specification will be stocked.",
info_map,
)
continue
initial_contents_by_storage_name[storage_name] = storage_contents
return initial_contents_by_storage_name

def _gather_field_data(self) -> dict[str, dict[str, Any]]:
"""
Gathers configuration data for all fields from the InputManager.
Expand Down Expand Up @@ -428,6 +476,29 @@ def _execute_animals_only_daily_simulation(self) -> None:

self._advance_time()

def _execute_feed_only_daily_simulation(self) -> None:
"""
Executes the daily simulation routines for a farm with only the feed module.

Daily Feed Only Simulation Process:
1. Feed storage upkeep (report storage levels, process degradations)
2. Record keeping (time, weather)
3. Advance simulation date

Notes
-----
The feed storages start the simulation holding the initial feed storage contents given in the inputs, and no
crops arrive after that: there is no crop and soil module to harvest any. Feed planning is not run either.
Without animals there is no feed demand to plan against, so the ideal feeds to hold for the planning cycle are
always empty and no feed is ever purchased. Nothing leaves storage other than through degradation.

"""
self._execute_feed_storage_upkeep()

self._report_daily_records()

self._advance_time()

def _execute_daily_field_operations(self) -> list[HarvestedCrop]:
"""Handles daily field operations including manure applications and crop harvesting/receiving."""
manure_applications: list[ManureEventNutrientRequestResults] = self._generate_daily_manure_applications()
Expand Down Expand Up @@ -531,6 +602,19 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No
else self._update_all_max_daily_feeds(total_projected_inventory, next_harvest_dates_with_rufas_ids)
)
self.feed_manager.manage_planning_cycle_purchases(ideal_feeds_to_purchase, self.time)

self._execute_feed_storage_upkeep()

def _execute_feed_storage_upkeep(self) -> None:
"""
Reports the state of feed storage, and processes feed degradations if the degradation interval has elapsed.

Notes
-----
This is the part of feed management that does not depend on anything outside the feed module, so a feed only
simulation runs it on every simulated day rather than only on days that feed planning occurs.

"""
self.feed_manager.report_feed_storage_levels(self.time.simulation_day, "daily_storage_levels")
self.feed_manager.report_cumulative_purchased_feeds(self.time.simulation_day)

Expand Down
1 change: 1 addition & 0 deletions changelog_WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@ This **WIP Changelog** records development changes in progress and not yet inclu
- [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()`.
- [3183](https://github.com/RuminantFarmSystems/RuFaS/pull/3183) - [minor change] [SimulationEngine] [Feed] [InputChange] [NoOutputChange] Adds a `feed_only` simulation type that runs the Feed Storage module without the crop and soil, animal, and manure modules, with feed storages given their starting contents by a new optional initial feed storage contents input.
9 changes: 9 additions & 0 deletions input/data/config/example_feed_only_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"start_date": "2013:1",
"end_date": "2019:365",
"set_seed": true,
"simulation_type": "feed_only",
"nutrient_standard": "NASEM",
"FIPS_county_code": 55025,
"include_detailed_values": false
}
Loading