From eca340aac84ac26c6c2a353dfa71c57313506f61 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:09:01 -0400 Subject: [PATCH 01/16] adds feedfulfillmentresults dataclass --- .../biophysical/feed_storage/feed_manager.py | 36 ++-- .../feed_storage_to_animal_connection.py | 52 +++++- RUFAS/simulation_engine.py | 174 ++++++++++++------ input/task_manager_metadata.json | 2 +- 4 files changed, 191 insertions(+), 73 deletions(-) diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index 9759b6ebbf..baeab4762d 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -11,6 +11,7 @@ FeedCategorization, FeedComponentType, RUFAS_ID, + FeedFulfillmentResults, NASEMFeed, NRCFeed, NutrientStandard, @@ -628,7 +629,7 @@ def _store_purchased_feed(self, rufas_id: RUFAS_ID, purchase_amount: float, time def _deduct_feeds_from_inventory( self, feeds_to_deduct: dict[RUFAS_ID, float], simulation_day: int - ) -> dict[str, dict[RUFAS_ID, float]]: + ) -> FeedFulfillmentResults: """ Removes feeds by RuFaS ID. Feed is deducted from farmgrown storages first (FIFO by storage_time), then purchased. @@ -642,10 +643,9 @@ def _deduct_feeds_from_inventory( Returns ------- - dict[str, dict[RUFAS_ID, float]] - A dictionary with two keys: 'purchased' and 'farmgrown'. Each key maps to another dictionary that contains - the RuFaS Feed IDs and the corresponding amounts of feed deducted (kg dry matter) from purchased and - farmgrown sources, respectively. + FeedFulfillmentResults + A data structure that tracks how much feed was deducted from purchased and farmgrown sources to fulfill + a request. Raises ------ @@ -661,12 +661,10 @@ def _deduct_feeds_from_inventory( farmgrown_by_id, purchased_by_id = self._gather_available_feeds_by_id() - total_purchased_deducted: dict[RUFAS_ID, float] = { - purchased_feed_id: 0.0 for purchased_feed_id in feeds_to_deduct - } - total_farmgrown_deducted: dict[RUFAS_ID, float] = { - farmgrown_id: 0.0 for farmgrown_id in self._gather_valid_farmgrown_feed_ids() - } + deduction_results = FeedFulfillmentResults.empty( + requested_feed_ids=list(feeds_to_deduct.keys()), + farmgrown_feed_ids=list(self._gather_valid_farmgrown_feed_ids()), + ) for feed_id, amount_needed in feeds_to_deduct.items(): remaining_amount_needed = float(amount_needed) @@ -677,15 +675,17 @@ def _deduct_feeds_from_inventory( farmgrown_by_id.get(feed_id, ()), ) if farmgrown_deducted: - total_farmgrown_deducted[feed_id] = total_farmgrown_deducted.get(feed_id, 0.0) + farmgrown_deducted + deduction_results.add_farmgrown(feed_id, farmgrown_deducted) remaining_amount_needed -= farmgrown_deducted if remaining_amount_needed > 1e-3: purchased_deducted = self._deduct_from_storage( - feed_id, remaining_amount_needed, purchased_by_id.get(feed_id, ()) + feed_id, + remaining_amount_needed, + purchased_by_id.get(feed_id, ()), ) if purchased_deducted: - total_purchased_deducted[feed_id] = total_purchased_deducted.get(feed_id, 0.0) + purchased_deducted + deduction_results.add_purchased(feed_id, purchased_deducted) remaining_amount_needed -= purchased_deducted if remaining_amount_needed > 1e-3: @@ -698,9 +698,13 @@ def _deduct_feeds_from_inventory( f"Not adequate feed to deduct remaining {remaining_amount_needed:.3f} kg DM of feed {feed_id}." ) - self._log_feed_deductions(total_purchased_deducted, total_farmgrown_deducted, simulation_day) + self._log_feed_deductions( + deduction_results.purchased, + deduction_results.farmgrown, + simulation_day, + ) - return {"purchased": total_purchased_deducted, "farmgrown": total_farmgrown_deducted} + return deduction_results def _log_feed_deductions( self, diff --git a/RUFAS/data_structures/feed_storage_to_animal_connection.py b/RUFAS/data_structures/feed_storage_to_animal_connection.py index 861de5c0c5..1a567e4d60 100644 --- a/RUFAS/data_structures/feed_storage_to_animal_connection.py +++ b/RUFAS/data_structures/feed_storage_to_animal_connection.py @@ -1,5 +1,5 @@ from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from enum import Enum @@ -395,6 +395,56 @@ def __rmul__(self, multiplier: int | float) -> "RequestedFeed": return multiplier * self +@dataclass +class FeedFulfillmentResults: + """Tracks how much feed was deducted from purchased and farmgrown sources to fulfill a request.""" + + purchased: dict[RUFAS_ID, float] = field(default_factory=dict) + farmgrown: dict[RUFAS_ID, float] = field(default_factory=dict) + + @classmethod + def fulfill_feed_request_as_purchased( + cls, requested_feed: RequestedFeed + ) -> "FeedFulfillmentResults": + """ + Create a fulfillment result where all requested feed is satisfied by purchased sources. + + This is used when the feed module is not simulated. + """ + return cls( + purchased=dict(requested_feed.requested_feed), + farmgrown={}, + ) + + @classmethod + def empty( + cls, + *, + requested_feed_ids: list[RUFAS_ID] | None = None, + farmgrown_feed_ids: list[RUFAS_ID] | None = None, + ) -> "FeedFulfillmentResults": + """Create an empty results object with initialized keys.""" + requested_feed_ids = requested_feed_ids or [] + farmgrown_feed_ids = farmgrown_feed_ids or [] + + return cls( + purchased={feed_id: 0.0 for feed_id in requested_feed_ids}, + farmgrown={feed_id: 0.0 for feed_id in farmgrown_feed_ids}, + ) + + def add_purchased(self, feed_id: RUFAS_ID, amount: float) -> None: + """Add deducted purchased feed for a given feed ID.""" + self.purchased[feed_id] = self.purchased.get(feed_id, 0.0) + amount + + def add_farmgrown(self, feed_id: RUFAS_ID, amount: float) -> None: + """Add deducted farmgrown feed for a given feed ID.""" + self.farmgrown[feed_id] = self.farmgrown.get(feed_id, 0.0) + amount + + def total_deducted_for_feed(self, feed_id: RUFAS_ID) -> float: + """Return total deducted amount for a single feed ID across all sources.""" + return self.purchased.get(feed_id, 0.0) + self.farmgrown.get(feed_id, 0.0) + + class PurchaseAllowance: """ Limits on amounts of feeds that may be purchased at a given time. diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 8e70d48fae..2b9f7c6e72 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -11,7 +11,7 @@ from RUFAS.biophysical.feed_storage.feed_manager import FeedManager from RUFAS.data_structures.animal_to_manure_connection import ManureStream from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop -from RUFAS.data_structures.feed_storage_to_animal_connection import NutrientStandard +from RUFAS.data_structures.feed_storage_to_animal_connection import FeedFulfillmentResults, NutrientStandard, TotalInventory from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -39,13 +39,51 @@ class SimulationType(Enum): @property def simulate_animals(self) -> bool: """Return whether this simulation type includes the animal module.""" - return self not in self._non_animal_simulation_types() + return self in self._animal_simulation_types() + + @property + def simulate_manure(self) -> bool: + """Return whether this simulation type includes the manure module.""" + return self in self._manure_simulation_types() + + @property + def simulate_fields(self) -> bool: + """Return whether this simulation type includes the crop, soil, and field module.""" + return self in self._fields_simulation_types() + + @property + def simulate_feed(self) -> bool: + """Return whether this simulation type includes the feed module.""" + return self in self._feed_simulation_types() @classmethod - def _non_animal_simulation_types(cls) -> set["SimulationType"]: - """Return the set of simulation types that do not simulate animals.""" + def _animal_simulation_types(cls) -> set["SimulationType"]: + """Return the set of simulation types that simulate animals.""" return { - cls.FIELD_AND_FEED, + cls.FULL_FARM, + } + + @classmethod + def _manure_simulation_types(cls) -> set["SimulationType"]: + """Return the set of simulation types that simulate manure processing.""" + return { + cls.FULL_FARM, + } + + @classmethod + def _fields_simulation_types(cls) -> set["SimulationType"]: + """Return the set of simulation types that simulate crops, soil, and fields.""" + return { + cls.FULL_FARM, + cls.FIELD_AND_FEED + } + + @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 } @classmethod @@ -106,6 +144,9 @@ def __init__(self, simulation_type: SimulationType) -> None: self.time = RufasTime() self.simulation_type = simulation_type self.simulate_animals = self.simulation_type.simulate_animals + self.simulate_fields = self.simulation_type.simulate_fields + self.simulate_manure = self.simulation_type.simulate_manure + self.simulate_feed = self.simulation_type.simulate_feed self._simulation_type_to_daily_simulation_function = { SimulationType.FULL_FARM: self._execute_full_farm_daily_simulation, SimulationType.FIELD_AND_FEED: self._execute_field_and_feed_daily_simulation, @@ -115,49 +156,55 @@ def __init__(self, simulation_type: SimulationType) -> None: def _initialize_simulation(self) -> None: """ - Instantiates the simulation object by requesting data from the Input Manager. + Instantiates the requested biophysical modules based on simulation type. """ weather_data = self.im.get_data("weather") self.om.time = self.time self.weather = Weather(weather_data, self.time) + self.emissions_estimator: EmissionsEstimator = EmissionsEstimator() - self.field_manager: FieldManager = FieldManager() - - nutrient_standard = NutrientStandard(self.im.get_data("config.nutrient_standard")) - feeds_config = self.im.get_data("feed") - feed_storage_configs = self.im.get_data("feed_storage_configurations") - feed_storage_instances = self.im.get_data("feed_storage_instances") - self.feed_manager: FeedManager = FeedManager( - feeds_config, - nutrient_standard, - feed_storage_configs, - feed_storage_instances, - ) - - ration_config = self.im.get_data("animal.ration") - ration_interval_length = ration_config["formulation_interval"] - self.ration_formulation_interval_length = timedelta(days=ration_interval_length) - self.next_ration_reformulation = self.time.current_date.date() - self.is_ration_defined_by_user = ration_config["user_input"] - max_daily_feed_recalculations_per_year: int = feeds_config["max_daily_feed_recalculations_per_year"] - self.max_daily_feed_recalculation_interval = timedelta(days=round(365 / max_daily_feed_recalculations_per_year)) - self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval - - self.herd_manager: HerdManager = HerdManager( - self.weather, - self.time, - is_ration_defined_by_user=self.is_ration_defined_by_user, - available_feeds=self.feed_manager.available_feeds, - simulate_animals=self.simulate_animals, - ) + if self.simulate_fields: + self.field_manager: FieldManager = FieldManager() + + if self.simulate_feed: + nutrient_standard = NutrientStandard(self.im.get_data("config.nutrient_standard")) + feeds_config = self.im.get_data("feed") + feed_storage_configs = self.im.get_data("feed_storage_configurations") + feed_storage_instances = self.im.get_data("feed_storage_instances") + self.feed_manager: FeedManager = FeedManager( + feeds_config, + nutrient_standard, + feed_storage_configs, + feed_storage_instances, + ) + feed_manager_available_feed_ids = [feed.rufas_id for feed in self.feed_manager.available_feeds] + self.emissions_estimator.check_available_purchased_feed_data(feed_manager_available_feed_ids) + max_daily_feed_recalculations_per_year: int = feeds_config["max_daily_feed_recalculations_per_year"] + self.max_daily_feed_recalculation_interval = timedelta( + days=round(365 / max_daily_feed_recalculations_per_year) + ) + self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval - self.manure_manager: ManureManager = ManureManager( - self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude - ) + if self.simulate_animals: + ration_config = self.im.get_data("animal.ration") + ration_interval_length = ration_config["formulation_interval"] + self.ration_formulation_interval_length = timedelta(days=ration_interval_length) + self.next_ration_reformulation = self.time.current_date.date() + self.is_ration_defined_by_user = ration_config["user_input"] + + self.herd_manager: HerdManager = HerdManager( + self.weather, + self.time, + is_ration_defined_by_user=self.is_ration_defined_by_user, + # TODO figure out what to send here if animals simulated but feed module is not + available_feeds=self.feed_manager.available_feeds, + simulate_animals=self.simulate_animals, + ) - self.emissions_estimator: EmissionsEstimator = EmissionsEstimator() - feed_manager_available_feed_ids = [feed.rufas_id for feed in self.feed_manager.available_feeds] - self.emissions_estimator.check_available_purchased_feed_data(feed_manager_available_feed_ids) + if self.simulate_manure: + self.manure_manager: ManureManager = ManureManager( + self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude + ) def simulate(self) -> None: """Executes the simulation.""" @@ -170,13 +217,15 @@ def simulate(self) -> None: self._run_simulation_main_loop() - AnimalModuleReporter.report_end_of_simulation( - self.herd_manager.herd_statistics, - self.herd_manager.herd_reproduction_statistics, - self.time, - self.herd_manager.heiferII_events_by_id, - self.herd_manager.cow_events_by_id, - ) + if self.simulate_animals: + AnimalModuleReporter.report_end_of_simulation( + self.herd_manager.herd_statistics, + self.herd_manager.herd_reproduction_statistics, + self.time, + self.herd_manager.heiferII_events_by_id, + self.herd_manager.cow_events_by_id, + ) + EEEManager.estimate_all() t_end_sim = timer.time() @@ -343,11 +392,14 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time ) self.feed_manager.manage_planning_cycle_purchases(ideal_feeds_to_purchase, self.time) + 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) def _execute_ration_planning(self) -> None: """Checks if it's time to reformulate the ration and executes ration formulation if needed.""" if self._is_time_to_reformulate_ration: - self._formulate_ration() + total_projected_inventory = self._prepare_feed_availability_projection() + self._formulate_ration(total_projected_inventory) @property def _is_time_to_reformulate_ration(self) -> bool: @@ -367,11 +419,12 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic - A dictionary mapping feed types to the amount of purchased feed fed to the herd. """ requested_feed = self.herd_manager.collect_daily_feed_request() - 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) - is_ok_to_feed_animals, daily_feeds_fed = self.feed_manager.manage_daily_feed_request(requested_feed, self.time) + is_ok_to_feed_animals, daily_feeds_fed = \ + self.feed_manager.manage_daily_feed_request(requested_feed, self.time) \ + if self.feed_manager is not None \ + else True, FeedFulfillmentResults.fulfill_feed_request_as_purchased(requested_feed) - daily_purchased_feeds_fed = daily_feeds_fed.get("purchased", {}) + daily_purchased_feeds_fed = daily_feeds_fed.purchased if not is_ok_to_feed_animals: info_map = {"class": self.__class__.__name__, "function": self._execute_daily_animal_operations.__name__} @@ -388,13 +441,24 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic return all_manure_data, daily_purchased_feeds_fed - def _formulate_ration(self) -> None: - """Formulates the ration for the animals.""" + def _prepare_feed_availability_projection(self) -> TotalInventory: + """ + Updates feed system state and projects feed availability over the next ration planning interval. + + Returns + ------- + TotalInventory + Total inventory of both farm grown and purchased feeds projected to be held at the current date. + """ self.feed_manager.process_degradations(self.weather, self.time) self.next_ration_reformulation = (self.time.current_date + self.ration_formulation_interval_length).date() total_projected_inventory = self.feed_manager.get_total_projected_inventory( self.next_ration_reformulation, self.weather, self.time ) + return total_projected_inventory + + def _formulate_ration(self, total_projected_inventory: TotalInventory) -> None: + """Formulates the ration for the animals.""" current_temperature = self.weather.get_current_day_conditions(time=self.time).mean_air_temperature requested_feed = self.herd_manager.formulate_rations( self.feed_manager.available_feeds, diff --git a/input/task_manager_metadata.json b/input/task_manager_metadata.json index 5674a5fcdc..1bde998055 100644 --- a/input/task_manager_metadata.json +++ b/input/task_manager_metadata.json @@ -3,7 +3,7 @@ "tasks": { "title": "Task manager data", "description": "Configuration file for general simulation parameters.", - "path": "input/data/tasks/example_freestall_task.json", + "path": "input/data/tasks/example_field_and_feed_task.json", "type": "json", "properties": "tasks_properties" } From 2da7e513008b5cf1b20c5caaeb1e4ffa3de2184f Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:36:26 -0400 Subject: [PATCH 02/16] moves available feeds creation to data structures --- .../biophysical/feed_storage/feed_manager.py | 90 +----------------- .../feed_storage_to_animal_connection.py | 93 +++++++++++++++++++ RUFAS/simulation_engine.py | 16 ++-- 3 files changed, 105 insertions(+), 94 deletions(-) diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index baeab4762d..91f014aee2 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -8,12 +8,8 @@ ) from RUFAS.data_structures.feed_storage_to_animal_connection import ( Feed, - FeedCategorization, - FeedComponentType, RUFAS_ID, FeedFulfillmentResults, - NASEMFeed, - NRCFeed, NutrientStandard, PlanningCycleAllowance, RuntimePurchaseAllowance, @@ -22,19 +18,14 @@ IdealFeeds, AdvancePurchaseAllowance, ) -from RUFAS.input_manager import InputManager from RUFAS.rufas_time import RufasTime from RUFAS.weather import Weather -from RUFAS.util import Utility from RUFAS.units import MeasurementUnits from RUFAS.output_manager import OutputManager from .storage import Storage from .purchased_feed_storage import PurchasedFeed, PurchasedFeedStorage -"""Ratio of the price of an on-farm price to the price of buying that feed from an off farm source.""" -ON_FARM_TO_PURCHASED_PRICE_RATION = 0.01 - """A type alias representing the context in which a feed purchase was initiated.""" PurchaseType = Literal["daily_feed_request", "ration_interval", "planning_cycle"] @@ -48,8 +39,6 @@ class FeedManager: ---------- feed_config : dict[str, list[Any]] Configuration for the feeds available in the simulation. - nutrient_standard : NutrientStandard - Nutrient standard used in the simulation (NASEM or NRC). crop_to_rufas_ids_mapping : dict[str, list[RUFAS_ID]] Mapping from crops to their corresponding RUFAS IDs. feed_storage_configs : dict[str, Any] @@ -88,12 +77,12 @@ class FeedManager: def __init__( self, feed_config: dict[str, list[Any]], - nutrient_standard: NutrientStandard, + available_feeds: list[Feed], feed_storage_configs: dict[str, Any], feed_storage_instances: dict[str, list[str]], ) -> None: self._om = OutputManager() - self._available_feeds: list[Feed] = self._setup_available_feeds(feed_config, nutrient_standard) + self._available_feeds = available_feeds self.active_storages: dict[str, Storage] = {} self._create_all_storages(feed_storage_configs, feed_storage_instances) @@ -875,77 +864,4 @@ def _gather_valid_farmgrown_feed_ids(self) -> set[RUFAS_ID]: farmgrown_ids.add(feed_id) return farmgrown_ids - def _setup_available_feeds( - self, feed_config: dict[str, list[Any]], nutrient_standard: NutrientStandard - ) -> list[Feed]: - """ - Creates list of feeds available for use in the simulation. - - Parameters - ---------- - feed_config : list[dict[str, Any]] - Mapping of the feeds available for purchase to the prices of those feeds. - nutrient_standard : NutrientStandard - Indicates whether the NASEM or NRC nutrient standards is being used. - - Returns - ------- - list[Feed] - Nutrition and price information of feeds available in the simulation. - - """ - feed_library = self._process_feed_library(nutrient_standard) - - feed_representation = NASEMFeed if nutrient_standard is NutrientStandard.NASEM else NRCFeed - available_feeds: list[Feed] = [] - feeds_to_parse = feed_config["purchased_feeds"] - for feed in feeds_to_parse: - rufas_id = feed["purchased_feed"] - price = feed["purchased_feed_cost"] - buffer = feed["buffer"] - try: - nutritive_properties = feed_library[rufas_id] - except KeyError: - raise KeyError(f"Feed with RUFAS ID '{rufas_id}' not found in the feed library.") - new_feed = feed_representation( - rufas_id=rufas_id, - amount_available=0.0, - on_farm_cost=price * ON_FARM_TO_PURCHASED_PRICE_RATION, - purchase_cost=price, - buffer=buffer, - **nutritive_properties, - ) - available_feeds.append(new_feed) - - sorted_available_feeds = sorted(available_feeds, key=lambda feed: feed.rufas_id) - return sorted_available_feeds - - def _process_feed_library(self, nutrient_standard: NutrientStandard) -> dict[RUFAS_ID, dict[str, Any]]: - """ - Collects and processes the feed library input so that it can be translated into a simulation-friendly format. - - Parameters - ---------- - nutrient_standard : NutrientStandard - Indicates whether the NASEM or NRC nutrient standards is being used. - - Returns - ------- - dict[RUFAS_ID, dict[str, Any]] - Mapping of RuFaS feed IDs to the nutritional properties of those feeds. - - """ - im = InputManager() - feed_library = ( - im.get_data("NASEM_Comp") if nutrient_standard is NutrientStandard.NASEM else im.get_data("NRC_Comp") - ) - - feed_library = Utility.convert_dict_of_lists_to_list_of_dicts(feed_library) - - feed_library = {feed["rufas_id"]: feed for feed in feed_library} - for feed in feed_library.values(): - del feed["rufas_id"] - feed["feed_type"] = FeedComponentType(feed["feed_type"]) - feed["Fd_Category"] = FeedCategorization(feed["Fd_Category"]) - feed["units"] = MeasurementUnits(feed["units"]) - return feed_library + diff --git a/RUFAS/data_structures/feed_storage_to_animal_connection.py b/RUFAS/data_structures/feed_storage_to_animal_connection.py index 1a567e4d60..bd64930f54 100644 --- a/RUFAS/data_structures/feed_storage_to_animal_connection.py +++ b/RUFAS/data_structures/feed_storage_to_animal_connection.py @@ -2,8 +2,11 @@ from dataclasses import dataclass, field from datetime import date from enum import Enum +from typing import Any +from RUFAS.input_manager import InputManager from RUFAS.units import MeasurementUnits +from RUFAS.util import Utility """ Every feed in RuFaS has a unique integer ID. They are defined in the Feed Library file used, and are used throughout @@ -11,6 +14,9 @@ """ RUFAS_ID = int +"""Ratio of the price of an on-farm price to the price of buying that feed from an off farm source.""" +ON_FARM_TO_PURCHASED_PRICE_RATION = 0.01 + class FeedCategorization(Enum): """NASEM and NRC categorizations of feeds.""" @@ -324,6 +330,93 @@ class NRCFeed(Feed): PAF: float +class AvailableFeedsBuilder: + """ + Builds the list of feeds available for use in the simulation. + + This class is responsible for loading feed composition data from the input + manager, translating it into simulation-friendly types, and constructing + the purchased feeds configured for the simulation. + """ + + @classmethod + def setup_available_feeds( + cls, feed_config: dict[str, list[Any]], nutrient_standard: NutrientStandard + ) -> list[Feed]: + """ + Creates sorted list of feeds available for use in the simulation. + + Parameters + ---------- + feed_config : list[dict[str, Any]] + Mapping of the feeds available for purchase to the prices of those feeds. + nutrient_standard : NutrientStandard + Indicates whether the NASEM or NRC nutrient standards is being used. + + Returns + ------- + list[Feed] + Nutrition and price information of feeds available in the simulation. + + """ + feed_library = cls._process_feed_library(nutrient_standard) + + feed_representation = NASEMFeed if nutrient_standard is NutrientStandard.NASEM else NRCFeed + available_feeds: list[Feed] = [] + feeds_to_parse = feed_config["purchased_feeds"] + for feed in feeds_to_parse: + rufas_id = feed["purchased_feed"] + price = feed["purchased_feed_cost"] + buffer = feed["buffer"] + try: + nutritive_properties = feed_library[rufas_id] + except KeyError: + raise KeyError(f"Feed with RUFAS ID '{rufas_id}' not found in the feed library.") + new_feed = feed_representation( + rufas_id=rufas_id, + amount_available=0.0, + on_farm_cost=price * ON_FARM_TO_PURCHASED_PRICE_RATION, + purchase_cost=price, + buffer=buffer, + **nutritive_properties, + ) + available_feeds.append(new_feed) + + return sorted(available_feeds, key=lambda feed: feed.rufas_id) + + @staticmethod + def _process_feed_library(nutrient_standard: NutrientStandard) -> dict[RUFAS_ID, dict[str, Any]]: + """ + Collects and processes the feed library input so that it can be translated into a simulation-friendly format. + + Parameters + ---------- + nutrient_standard : NutrientStandard + Indicates whether the NASEM or NRC nutrient standards is being used. + + Returns + ------- + dict[RUFAS_ID, dict[str, Any]] + Mapping of RuFaS feed IDs to the nutritional properties of those feeds. + + """ + im = InputManager() + feed_library: dict[str, list[Any]] = ( + im.get_data("NASEM_Comp") if nutrient_standard is NutrientStandard.NASEM else im.get_data("NRC_Comp") + ) + + converted_feed_library = Utility.convert_dict_of_lists_to_list_of_dicts(feed_library) + + processed_feed_library = {feed["rufas_id"]: feed for feed in converted_feed_library} + for feed in processed_feed_library.values(): + del feed["rufas_id"] + feed["feed_type"] = FeedComponentType(feed["feed_type"]) + feed["Fd_Category"] = FeedCategorization(feed["Fd_Category"]) + feed["units"] = MeasurementUnits(feed["units"]) + return processed_feed_library + + + @dataclass class TotalInventory: """ diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 2b9f7c6e72..5d0c881663 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -11,7 +11,7 @@ from RUFAS.biophysical.feed_storage.feed_manager import FeedManager from RUFAS.data_structures.animal_to_manure_connection import ManureStream from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop -from RUFAS.data_structures.feed_storage_to_animal_connection import FeedFulfillmentResults, NutrientStandard, TotalInventory +from RUFAS.data_structures.feed_storage_to_animal_connection import AvailableFeedsBuilder, FeedFulfillmentResults, NutrientStandard, TotalInventory from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -166,18 +166,21 @@ def _initialize_simulation(self) -> None: if self.simulate_fields: self.field_manager: FieldManager = FieldManager() - if self.simulate_feed: - nutrient_standard = NutrientStandard(self.im.get_data("config.nutrient_standard")) + if self.simulate_animals or self.simulate_feed: feeds_config = self.im.get_data("feed") + nutrient_standard = NutrientStandard(self.im.get_data("config.nutrient_standard")) + available_feeds = AvailableFeedsBuilder.setup_available_feeds(feeds_config, nutrient_standard) + + if self.simulate_feed: feed_storage_configs = self.im.get_data("feed_storage_configurations") feed_storage_instances = self.im.get_data("feed_storage_instances") self.feed_manager: FeedManager = FeedManager( feeds_config, - nutrient_standard, + available_feeds, feed_storage_configs, feed_storage_instances, ) - feed_manager_available_feed_ids = [feed.rufas_id for feed in self.feed_manager.available_feeds] + feed_manager_available_feed_ids = [feed.rufas_id for feed in available_feeds] self.emissions_estimator.check_available_purchased_feed_data(feed_manager_available_feed_ids) max_daily_feed_recalculations_per_year: int = feeds_config["max_daily_feed_recalculations_per_year"] self.max_daily_feed_recalculation_interval = timedelta( @@ -196,8 +199,7 @@ def _initialize_simulation(self) -> None: self.weather, self.time, is_ration_defined_by_user=self.is_ration_defined_by_user, - # TODO figure out what to send here if animals simulated but feed module is not - available_feeds=self.feed_manager.available_feeds, + available_feeds=available_feeds, simulate_animals=self.simulate_animals, ) From 778a2c4a5799208ce45f5b4ca074df55739c244d Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:30:09 -0400 Subject: [PATCH 03/16] disentangles feed and animal module data reliance --- RUFAS/biophysical/animal/herd_manager.py | 37 ++--------- RUFAS/biophysical/animal/pen.py | 3 - .../biophysical/feed_storage/feed_manager.py | 1 - RUFAS/simulation_engine.py | 64 ++++++++----------- tests/test_simulation_engine.py | 6 +- 5 files changed, 35 insertions(+), 76 deletions(-) diff --git a/RUFAS/biophysical/animal/herd_manager.py b/RUFAS/biophysical/animal/herd_manager.py index c80d1ba12d..d89d012934 100644 --- a/RUFAS/biophysical/animal/herd_manager.py +++ b/RUFAS/biophysical/animal/herd_manager.py @@ -498,25 +498,24 @@ def _update_herd_structure( removed_animals: list[Animal], available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """Call the corresponding functions to update the herd structure and reassign animals to new pens.""" self._handle_graduated_animals( - graduated_animals, available_feeds, current_day_conditions, total_inventory, simulation_day + graduated_animals, available_feeds, current_day_conditions, simulation_day ) self._handle_newly_added_animals( - newborn_calves, available_feeds, current_day_conditions, total_inventory, simulation_day + newborn_calves, available_feeds, current_day_conditions, simulation_day ) self._handle_newly_added_animals( - newly_added_animals, available_feeds, current_day_conditions, total_inventory, simulation_day + newly_added_animals, available_feeds, current_day_conditions, simulation_day ) for removed_animal in removed_animals: self._remove_animal_from_pen_and_id_map(removed_animal) def daily_routines( - self, available_feeds: list[Feed], time: RufasTime, weather: Weather, total_inventory: TotalInventory + self, available_feeds: list[Feed], time: RufasTime, weather: Weather, ) -> dict[str, ManureStream]: """ Perform daily routines for managing animal herds and updating associated data. @@ -533,8 +532,6 @@ def daily_routines( An instance of the RufasTime object representing the current time and simulation day. weather : Weather An object providing weather conditions affecting herd activities. - total_inventory : TotalInventory - Object representing the total inventory of herd-related resources. Returns ------- @@ -610,7 +607,6 @@ def daily_routines( removed_animals=removed_animals, available_feeds=available_feeds, current_day_conditions=weather.get_current_day_conditions(time), - total_inventory=total_inventory, simulation_day=time.simulation_day, ) @@ -822,7 +818,6 @@ def _handle_graduated_animals( graduated_animals: list[Animal], available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -836,8 +831,6 @@ def _handle_graduated_animals( Nutrition information of feeds available to formulate animals rations with. current_day_conditions : CurrentDayConditions Object representing the current conditions of the day. - total_inventory : TotalInventory - Inventory currently available or projected to be available at a future date. simulation_day : int Day of simulation. @@ -846,7 +839,7 @@ def _handle_graduated_animals( self._remove_animal_from_pen_and_id_map(animal) self._update_animal_array(animal) self._add_animal_to_pen_and_id_map( - animal, available_feeds, current_day_conditions, total_inventory, simulation_day + animal, available_feeds, current_day_conditions, simulation_day ) def _handle_newly_added_animals( @@ -854,7 +847,6 @@ def _handle_newly_added_animals( new_animals: list[Animal], available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -868,15 +860,13 @@ def _handle_newly_added_animals( Nutrition information of feeds available to formulate animals rations with. current_day_conditions : CurrentDayConditions Object representing the current conditions of the day. - total_inventory : TotalInventory - Inventory currently available or projected to be available at a future date. simulation_day: int Day of simulation. """ for animal in new_animals: self._add_animal_to_pen_and_id_map( - animal, available_feeds, current_day_conditions, total_inventory, simulation_day + animal, available_feeds, current_day_conditions, simulation_day ) self._add_animal_to_new_array(animal) @@ -900,7 +890,6 @@ def _add_animal_to_pen_and_id_map( animal: Animal, available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -914,8 +903,6 @@ def _add_animal_to_pen_and_id_map( Nutrition information of feeds available to formulate animals rations with. current_day_conditions : CurrentDayConditions Object representing the current conditions of the day. - total_inventory : TotalInventory - Inventory currently available or projected to be available at a future date. simulation_day : int Day of simulation. @@ -944,7 +931,6 @@ def _add_animal_to_pen_and_id_map( pen=pen_with_min_stocking_density, pen_available_feeds=pen_available_feeds, current_temperature=current_day_conditions.mean_air_temperature, - total_inventory=total_inventory, simulation_day=simulation_day, ) @@ -1364,8 +1350,6 @@ def update_all_max_daily_feeds( The maximum daily feeds for each feed type. """ - if not self.simulate_animals: - return IdealFeeds({}) for rufas_id in next_harvest_dates.keys(): self._update_single_max_daily_feed(rufas_id, next_harvest_dates[rufas_id], total_inventory, time) @@ -1408,7 +1392,6 @@ def formulate_rations( available_feeds: list[Feed], current_temperature: float, ration_interval_length: int, - total_inventory: TotalInventory, simulation_day: int, ) -> RequestedFeed: """ @@ -1422,8 +1405,6 @@ def formulate_rations( Current temperature (C). ration_interval_length : int Length of the ration interval (days). - total_inventory : TotalInventory - The total inventory of all available feeds. simulation_day : int Day of simulation. @@ -1449,7 +1430,7 @@ def formulate_rations( ration_feed_ids = RationManager.get_ration_feeds(pen.animal_combination) pen_available_feeds = self._find_pen_available_feeds(available_feeds, ration_feed_ids) self._reformulate_ration_single_pen( - pen, pen_available_feeds, current_temperature, total_inventory, simulation_day + pen, pen_available_feeds, current_temperature, simulation_day ) total_requested_feed += pen.get_requested_feed(ration_interval_length) return total_requested_feed @@ -1459,7 +1440,6 @@ def _reformulate_ration_single_pen( pen: Pen, pen_available_feeds: list[Feed], current_temperature: float, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -1473,8 +1453,6 @@ def _reformulate_ration_single_pen( List of available feeds in this pen. current_temperature : float Current temperature (C). - total_inventory : TotalInventory - Inventory currently available or projected to be available at a future date. simulation_day : int Day of simulation. @@ -1497,7 +1475,6 @@ def _reformulate_ration_single_pen( current_temperature, self._max_daily_feeds, self.advance_purchase_allowance, - total_inventory, simulation_day, ) diff --git a/RUFAS/biophysical/animal/pen.py b/RUFAS/biophysical/animal/pen.py index de69a3ace5..0d43761ca9 100644 --- a/RUFAS/biophysical/animal/pen.py +++ b/RUFAS/biophysical/animal/pen.py @@ -1031,7 +1031,6 @@ def formulate_optimized_ration( # noqa: C901 temperature: float, max_daily_feeds: dict[RUFAS_ID, float], advance_purchase_allowance: AdvancePurchaseAllowance, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -1047,8 +1046,6 @@ def formulate_optimized_ration( # noqa: C901 Maximum amounts of each feed type that may be fed per animal per day. advance_purchase_allowance : AdvancePurchaseAllowance Maximum amounts of each feed type that may be purchased at the beginning of a feed interval. - total_inventory : TotalInventory - Amounts of feeds currently held in storage. simulation_day : int Day of simulation. diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index 91f014aee2..5d51f8a8e0 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -10,7 +10,6 @@ Feed, RUFAS_ID, FeedFulfillmentResults, - NutrientStandard, PlanningCycleAllowance, RuntimePurchaseAllowance, RequestedFeed, diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 5d0c881663..62a62e4732 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -11,7 +11,7 @@ from RUFAS.biophysical.feed_storage.feed_manager import FeedManager from RUFAS.data_structures.animal_to_manure_connection import ManureStream from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop -from RUFAS.data_structures.feed_storage_to_animal_connection import AvailableFeedsBuilder, FeedFulfillmentResults, NutrientStandard, TotalInventory +from RUFAS.data_structures.feed_storage_to_animal_connection import AvailableFeedsBuilder, FeedFulfillmentResults, IdealFeeds, NutrientStandard, TotalInventory from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -169,18 +169,18 @@ def _initialize_simulation(self) -> None: if self.simulate_animals or self.simulate_feed: feeds_config = self.im.get_data("feed") nutrient_standard = NutrientStandard(self.im.get_data("config.nutrient_standard")) - available_feeds = AvailableFeedsBuilder.setup_available_feeds(feeds_config, nutrient_standard) + self.available_feeds = AvailableFeedsBuilder.setup_available_feeds(feeds_config, nutrient_standard) if self.simulate_feed: feed_storage_configs = self.im.get_data("feed_storage_configurations") feed_storage_instances = self.im.get_data("feed_storage_instances") self.feed_manager: FeedManager = FeedManager( feeds_config, - available_feeds, + self.available_feeds, feed_storage_configs, feed_storage_instances, ) - feed_manager_available_feed_ids = [feed.rufas_id for feed in available_feeds] + 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["max_daily_feed_recalculations_per_year"] self.max_daily_feed_recalculation_interval = timedelta( @@ -199,7 +199,7 @@ def _initialize_simulation(self) -> None: self.weather, self.time, is_ration_defined_by_user=self.is_ration_defined_by_user, - available_feeds=available_feeds, + available_feeds=self.available_feeds, simulate_animals=self.simulate_animals, ) @@ -300,17 +300,18 @@ def _execute_field_and_feed_daily_simulation(self) -> None: 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() + manure_applications: list[ManureEventNutrientRequestResults] = self._generate_daily_manure_applications() harvested_crops: list[HarvestedCrop] = self.field_manager.daily_update_routine( self.weather, self.time, manure_applications ) + # TODO move to a feed-related action rather than field? for crop in harvested_crops: self.feed_manager.receive_crop(crop, self.time.simulation_day) return harvested_crops - def generate_daily_manure_applications(self) -> list[ManureEventNutrientRequestResults]: + def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequestResults]: """Requests nutrients from the manure manager for each field in the simulation. Returns @@ -327,6 +328,7 @@ def generate_daily_manure_applications(self) -> list[ManureEventNutrientRequestR manure_request = manure_event_request.nutrient_request manure_request_results = None if manure_request is not None: + # TODO this should go to shared data structures? manure_request_results = self.manure_manager.request_nutrients( manure_request, self.simulate_animals, self.time ) @@ -358,6 +360,7 @@ def _build_harvest_schedule(self, harvested_crops: list[HarvestedCrop]) -> dict[ harvest_schedule_crops = set(crop.config_name for crop in harvested_crops) if self._should_recalculate_feed_planning: + # TODO this needs to be removed from here because it's a feed_manager operation crops_to_get_next_harvest_dates = [ crop for crop in self.feed_manager.crop_to_rufas_id.keys() if crop not in harvest_schedule_crops ] @@ -390,7 +393,8 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No ) next_harvest_dates_with_rufas_ids = self.feed_manager.translate_crop_config_name_to_rufas_id(harvest_schedule) - ideal_feeds_to_purchase = self.herd_manager.update_all_max_daily_feeds( + ideal_feeds_to_purchase = IdealFeeds({}) if not self.simulate_animals else \ + self.herd_manager.update_all_max_daily_feeds( total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time ) self.feed_manager.manage_planning_cycle_purchases(ideal_feeds_to_purchase, self.time) @@ -400,13 +404,16 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No def _execute_ration_planning(self) -> None: """Checks if it's time to reformulate the ration and executes ration formulation if needed.""" if self._is_time_to_reformulate_ration: - total_projected_inventory = self._prepare_feed_availability_projection() - self._formulate_ration(total_projected_inventory) + if self.simulate_feed: + self.feed_manager.process_degradations(self.weather, self.time) + self.next_ration_reformulation = (self.time.current_date + self.ration_formulation_interval_length).date() + self._formulate_ration() @property def _is_time_to_reformulate_ration(self) -> bool: """Checks if it's time to reformulate the ration based on the user-defined interval.""" - return self.time.current_date.date() >= self.next_ration_reformulation + return self.time.current_date.date() >= self.next_ration_reformulation \ + if self.next_ration_reformulation else False def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dict[int, float]]: """ @@ -433,47 +440,26 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic self.om.add_warning("Value: not enough feed for the herd", "Reformulating ration for all pens", info_map) self._formulate_ration() - total_inventory = self.feed_manager.get_total_projected_inventory( - self.time.current_date.date(), self.weather, self.time - ) - all_manure_data = self.herd_manager.daily_routines( - self.feed_manager.available_feeds, self.time, self.weather, total_inventory + self.available_feeds, self.time, self.weather, ) return all_manure_data, daily_purchased_feeds_fed - def _prepare_feed_availability_projection(self) -> TotalInventory: - """ - Updates feed system state and projects feed availability over the next ration planning interval. - - Returns - ------- - TotalInventory - Total inventory of both farm grown and purchased feeds projected to be held at the current date. - """ - self.feed_manager.process_degradations(self.weather, self.time) - self.next_ration_reformulation = (self.time.current_date + self.ration_formulation_interval_length).date() - total_projected_inventory = self.feed_manager.get_total_projected_inventory( - self.next_ration_reformulation, self.weather, self.time - ) - return total_projected_inventory - - def _formulate_ration(self, total_projected_inventory: TotalInventory) -> None: + def _formulate_ration(self) -> None: """Formulates the ration for the animals.""" current_temperature = self.weather.get_current_day_conditions(time=self.time).mean_air_temperature requested_feed = self.herd_manager.formulate_rations( - self.feed_manager.available_feeds, + self.available_feeds, current_temperature, self.ration_formulation_interval_length.days, - total_projected_inventory, self.time.simulation_day, ) - self.feed_manager.manage_ration_interval_purchases(requested_feed, self.time) - - self.herd_manager.report_ration_interval_data(self.time.simulation_day) + if self.simulate_feed: + self.feed_manager.manage_ration_interval_purchases(requested_feed, self.time) + self.feed_manager.report_feed_manager_balance(self.time.simulation_day) - self.feed_manager.report_feed_manager_balance(self.time.simulation_day) + self.herd_manager.report_ration_interval_data(self.time.simulation_day) def _execute_daily_manure_operations(self, daily_manure_data: dict[str, ManureStream] | None) -> None: """ diff --git a/tests/test_simulation_engine.py b/tests/test_simulation_engine.py index 01f710d2b1..cf35de0d03 100644 --- a/tests/test_simulation_engine.py +++ b/tests/test_simulation_engine.py @@ -313,7 +313,7 @@ def test_execute_daily_field_operations( mock_generate_daily_manure_applications = mocker.patch.object( simulation_engine, - "generate_daily_manure_applications", + "_generate_daily_manure_applications", return_value=manure_applications, ) @@ -357,7 +357,7 @@ def test_execute_daily_field_operations_no_harvested_crops( mock_generate_daily_manure_applications = mocker.patch.object( simulation_engine, - "generate_daily_manure_applications", + "_generate_daily_manure_applications", return_value=manure_applications, ) @@ -993,7 +993,7 @@ def test_generate_daily_manure_applications(simulation_engine: SimulationEngine, simulation_engine.manure_manager, "request_nutrients", return_value=mock_nutrient_request_result ) - result = simulation_engine.generate_daily_manure_applications() + result = simulation_engine._generate_daily_manure_applications() assert result == [ ManureEventNutrientRequestResults("Field 1", mock_event_1, mock_nutrient_request_result), From f7ce8b95f08b2adf981451a9d5763fe2c13653ac Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:51:49 -0400 Subject: [PATCH 04/16] separates ration interval from feed degradations interval --- RUFAS/simulation_engine.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 62a62e4732..9e26119304 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -11,7 +11,12 @@ from RUFAS.biophysical.feed_storage.feed_manager import FeedManager from RUFAS.data_structures.animal_to_manure_connection import ManureStream from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop -from RUFAS.data_structures.feed_storage_to_animal_connection import AvailableFeedsBuilder, FeedFulfillmentResults, IdealFeeds, NutrientStandard, TotalInventory +from RUFAS.data_structures.feed_storage_to_animal_connection import ( + AvailableFeedsBuilder, + FeedFulfillmentResults, + IdealFeeds, + NutrientStandard, +) from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -21,6 +26,9 @@ from RUFAS.weather import Weather +DEFAULT_FEED_DEGRADATIONS_PROCESSING_INTERVAL = 30 + + class SimulationType(Enum): """ An enumeration for the different types of simulations that can be run in RuFaS. @@ -187,6 +195,8 @@ def _initialize_simulation(self) -> None: days=round(365 / max_daily_feed_recalculations_per_year) ) self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval + self.feed_degradations_interval_length = timedelta(days=DEFAULT_FEED_DEGRADATIONS_PROCESSING_INTERVAL) + self.next_degredations_processing = self.time.current_date.date() if self.simulate_animals: ration_config = self.im.get_data("animal.ration") @@ -292,8 +302,6 @@ def _execute_field_and_feed_daily_simulation(self) -> None: harvest_schedule = self._build_harvest_schedule(daily_harvested_crops) self._execute_feed_planning(harvest_schedule) - self._execute_ration_planning() - self._report_daily_records() self._advance_time() @@ -401,19 +409,25 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No 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) + if self._is_time_to_process_feed_degredations: + self.next_degredations_processing = (self.time.current_date + self.feed_degradations_interval_length).date() + self.feed_manager.process_degradations(self.weather, self.time) + + @property + def _is_time_to_process_feed_degredations(self) -> bool: + """Checks if it's time to process feed degredations""" + return self.time.current_date.date() >= self.next_degredations_processing + def _execute_ration_planning(self) -> None: """Checks if it's time to reformulate the ration and executes ration formulation if needed.""" if self._is_time_to_reformulate_ration: - if self.simulate_feed: - self.feed_manager.process_degradations(self.weather, self.time) self.next_ration_reformulation = (self.time.current_date + self.ration_formulation_interval_length).date() self._formulate_ration() @property def _is_time_to_reformulate_ration(self) -> bool: """Checks if it's time to reformulate the ration based on the user-defined interval.""" - return self.time.current_date.date() >= self.next_ration_reformulation \ - if self.next_ration_reformulation else False + return self.time.current_date.date() >= self.next_ration_reformulation def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dict[int, float]]: """ From ad38e6cf9731249d9c3c70e172e15c98337771d9 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:52:50 -0400 Subject: [PATCH 05/16] spell check on degradations --- RUFAS/simulation_engine.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 9e26119304..deee455cd6 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -196,7 +196,7 @@ def _initialize_simulation(self) -> None: ) self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval self.feed_degradations_interval_length = timedelta(days=DEFAULT_FEED_DEGRADATIONS_PROCESSING_INTERVAL) - self.next_degredations_processing = self.time.current_date.date() + self.next_degradations_processing = self.time.current_date.date() if self.simulate_animals: ration_config = self.im.get_data("animal.ration") @@ -409,14 +409,14 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No 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) - if self._is_time_to_process_feed_degredations: - self.next_degredations_processing = (self.time.current_date + self.feed_degradations_interval_length).date() + if self._is_time_to_process_feed_degradations: + self.next_degradations_processing = (self.time.current_date + self.feed_degradations_interval_length).date() self.feed_manager.process_degradations(self.weather, self.time) @property - def _is_time_to_process_feed_degredations(self) -> bool: - """Checks if it's time to process feed degredations""" - return self.time.current_date.date() >= self.next_degredations_processing + def _is_time_to_process_feed_degradations(self) -> bool: + """Checks if it's time to process feed degradations""" + return self.time.current_date.date() >= self.next_degradations_processing def _execute_ration_planning(self) -> None: """Checks if it's time to reformulate the ration and executes ration formulation if needed.""" From 818a4681802ac101332c801928e20ee49e9cc500 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:42:28 -0400 Subject: [PATCH 06/16] iniitalizes each manager to none in simeng init --- RUFAS/simulation_engine.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index deee455cd6..60f199536d 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -150,6 +150,10 @@ def __init__(self, simulation_type: SimulationType) -> None: self.om = OutputManager() self.im = InputManager() self.time = RufasTime() + self.herd_manager = None + self.field_manager = None + self.feed_manager = None + self.manure_manager = None self.simulation_type = simulation_type self.simulate_animals = self.simulation_type.simulate_animals self.simulate_fields = self.simulation_type.simulate_fields @@ -213,10 +217,11 @@ def _initialize_simulation(self) -> None: simulate_animals=self.simulate_animals, ) - if self.simulate_manure: - self.manure_manager: ManureManager = ManureManager( - self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude - ) + # if self.simulate_manure: + # TODO need to isolate manure request fulfillment so field operations won't need full Manure module + self.manure_manager: ManureManager = ManureManager( + self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude + ) def simulate(self) -> None: """Executes the simulation.""" @@ -336,7 +341,7 @@ def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequest manure_request = manure_event_request.nutrient_request manure_request_results = None if manure_request is not None: - # TODO this should go to shared data structures? + # TODO figure out how to generate manure request with no manure module. manure_request_results = self.manure_manager.request_nutrients( manure_request, self.simulate_animals, self.time ) From 13eee3e60b2e51398986ff179b87d4f386b8db71 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:44:59 -0400 Subject: [PATCH 07/16] revert task mgr metadata task --- input/task_manager_metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/input/task_manager_metadata.json b/input/task_manager_metadata.json index 1bde998055..5674a5fcdc 100644 --- a/input/task_manager_metadata.json +++ b/input/task_manager_metadata.json @@ -3,7 +3,7 @@ "tasks": { "title": "Task manager data", "description": "Configuration file for general simulation parameters.", - "path": "input/data/tasks/example_field_and_feed_task.json", + "path": "input/data/tasks/example_freestall_task.json", "type": "json", "properties": "tasks_properties" } From a9fa3e7df6cccc2e020850640ac65c8d4ae3ccb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 14:49:52 +0000 Subject: [PATCH 08/16] Apply Black Formatting --- RUFAS/biophysical/animal/herd_manager.py | 29 ++++++--------- .../biophysical/feed_storage/feed_manager.py | 2 -- .../feed_storage_to_animal_connection.py | 5 +-- RUFAS/simulation_engine.py | 35 +++++++++---------- 4 files changed, 28 insertions(+), 43 deletions(-) diff --git a/RUFAS/biophysical/animal/herd_manager.py b/RUFAS/biophysical/animal/herd_manager.py index d89d012934..5c31672d3e 100644 --- a/RUFAS/biophysical/animal/herd_manager.py +++ b/RUFAS/biophysical/animal/herd_manager.py @@ -501,21 +501,18 @@ def _update_herd_structure( simulation_day: int, ) -> None: """Call the corresponding functions to update the herd structure and reassign animals to new pens.""" - self._handle_graduated_animals( - graduated_animals, available_feeds, current_day_conditions, simulation_day - ) - self._handle_newly_added_animals( - newborn_calves, available_feeds, current_day_conditions, simulation_day - ) - self._handle_newly_added_animals( - newly_added_animals, available_feeds, current_day_conditions, simulation_day - ) + self._handle_graduated_animals(graduated_animals, available_feeds, current_day_conditions, simulation_day) + self._handle_newly_added_animals(newborn_calves, available_feeds, current_day_conditions, simulation_day) + self._handle_newly_added_animals(newly_added_animals, available_feeds, current_day_conditions, simulation_day) for removed_animal in removed_animals: self._remove_animal_from_pen_and_id_map(removed_animal) def daily_routines( - self, available_feeds: list[Feed], time: RufasTime, weather: Weather, + self, + available_feeds: list[Feed], + time: RufasTime, + weather: Weather, ) -> dict[str, ManureStream]: """ Perform daily routines for managing animal herds and updating associated data. @@ -838,9 +835,7 @@ def _handle_graduated_animals( for animal in graduated_animals: self._remove_animal_from_pen_and_id_map(animal) self._update_animal_array(animal) - self._add_animal_to_pen_and_id_map( - animal, available_feeds, current_day_conditions, simulation_day - ) + self._add_animal_to_pen_and_id_map(animal, available_feeds, current_day_conditions, simulation_day) def _handle_newly_added_animals( self, @@ -865,9 +860,7 @@ def _handle_newly_added_animals( """ for animal in new_animals: - self._add_animal_to_pen_and_id_map( - animal, available_feeds, current_day_conditions, simulation_day - ) + self._add_animal_to_pen_and_id_map(animal, available_feeds, current_day_conditions, simulation_day) self._add_animal_to_new_array(animal) def _remove_animal_from_pen_and_id_map(self, animal: Animal) -> None: @@ -1429,9 +1422,7 @@ def formulate_rations( else: ration_feed_ids = RationManager.get_ration_feeds(pen.animal_combination) pen_available_feeds = self._find_pen_available_feeds(available_feeds, ration_feed_ids) - self._reformulate_ration_single_pen( - pen, pen_available_feeds, current_temperature, simulation_day - ) + self._reformulate_ration_single_pen(pen, pen_available_feeds, current_temperature, simulation_day) total_requested_feed += pen.get_requested_feed(ration_interval_length) return total_requested_feed diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index 5d51f8a8e0..4b26e3387a 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -862,5 +862,3 @@ def _gather_valid_farmgrown_feed_ids(self) -> set[RUFAS_ID]: if feed_id in valid_feed_ids: farmgrown_ids.add(feed_id) return farmgrown_ids - - diff --git a/RUFAS/data_structures/feed_storage_to_animal_connection.py b/RUFAS/data_structures/feed_storage_to_animal_connection.py index bd64930f54..22934e7332 100644 --- a/RUFAS/data_structures/feed_storage_to_animal_connection.py +++ b/RUFAS/data_structures/feed_storage_to_animal_connection.py @@ -416,7 +416,6 @@ def _process_feed_library(nutrient_standard: NutrientStandard) -> dict[RUFAS_ID, return processed_feed_library - @dataclass class TotalInventory: """ @@ -496,9 +495,7 @@ class FeedFulfillmentResults: farmgrown: dict[RUFAS_ID, float] = field(default_factory=dict) @classmethod - def fulfill_feed_request_as_purchased( - cls, requested_feed: RequestedFeed - ) -> "FeedFulfillmentResults": + def fulfill_feed_request_as_purchased(cls, requested_feed: RequestedFeed) -> "FeedFulfillmentResults": """ Create a fulfillment result where all requested feed is satisfied by purchased sources. diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 835559c8f6..cfadbba4e3 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -26,7 +26,6 @@ from RUFAS.rufas_time import RufasTime from RUFAS.weather import Weather - DEFAULT_FEED_DEGRADATIONS_PROCESSING_INTERVAL = 30 @@ -82,18 +81,12 @@ def _manure_simulation_types(cls) -> set["SimulationType"]: @classmethod def _fields_simulation_types(cls) -> set["SimulationType"]: """Return the set of simulation types that simulate crops, soil, and fields.""" - return { - cls.FULL_FARM, - cls.FIELD_AND_FEED - } + return {cls.FULL_FARM, cls.FIELD_AND_FEED} @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} @classmethod def get_simulation_type(cls, simulation_type: str) -> "SimulationType": @@ -409,9 +402,12 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No ) next_harvest_dates_with_rufas_ids = self.feed_manager.translate_crop_config_name_to_rufas_id(harvest_schedule) - ideal_feeds_to_purchase = IdealFeeds({}) if not self.simulate_animals else \ - self.herd_manager.update_all_max_daily_feeds( - total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time + ideal_feeds_to_purchase = ( + IdealFeeds({}) + if not self.simulate_animals + else self.herd_manager.update_all_max_daily_feeds( + total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time + ) ) self.feed_manager.manage_planning_cycle_purchases(ideal_feeds_to_purchase, self.time) self.feed_manager.report_feed_storage_levels(self.time.simulation_day, "daily_storage_levels") @@ -450,10 +446,11 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic - A dictionary mapping feed types to the amount of purchased feed fed to the herd. """ requested_feed = self.herd_manager.collect_daily_feed_request() - is_ok_to_feed_animals, daily_feeds_fed = \ - self.feed_manager.manage_daily_feed_request(requested_feed, self.time) \ - if self.feed_manager is not None \ - else True, FeedFulfillmentResults.fulfill_feed_request_as_purchased(requested_feed) + is_ok_to_feed_animals, daily_feeds_fed = ( + self.feed_manager.manage_daily_feed_request(requested_feed, self.time) + if self.feed_manager is not None + else True + ), FeedFulfillmentResults.fulfill_feed_request_as_purchased(requested_feed) daily_purchased_feeds_fed = daily_feeds_fed.purchased @@ -463,7 +460,9 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic self._formulate_ration() all_manure_data = self.herd_manager.daily_routines( - self.available_feeds, self.time, self.weather, + self.available_feeds, + self.time, + self.weather, ) return all_manure_data, daily_purchased_feeds_fed @@ -481,7 +480,7 @@ def _formulate_ration(self) -> None: self.feed_manager.manage_ration_interval_purchases(requested_feed, self.time) self.feed_manager.report_feed_manager_balance(self.time.simulation_day) - self.herd_manager.report_ration_interval_data(self.time.simulation_day) + self.herd_manager.report_ration_interval_data(self.time.simulation_day) def _execute_daily_manure_operations(self, daily_manure_data: dict[str, ManureStream] | None) -> None: """ From 00966fd950055961921d9f616ac54507a55b5734 Mon Sep 17 00:00:00 2001 From: ew3361zh Date: Wed, 8 Apr 2026 14:53:53 +0000 Subject: [PATCH 09/16] Update badges on README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3e1485b21f..828545d2cc 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-1191%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-1274%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems From 13da139e28314c7eb85aba02c81da0f570d013c6 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:32:59 -0400 Subject: [PATCH 10/16] updates from doing review in wt --- RUFAS/simulation_engine.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index cfadbba4e3..9f4635c94b 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -144,10 +144,6 @@ def __init__(self, simulation_type: SimulationType) -> None: self.om = OutputManager() self.im = InputManager() self.time = RufasTime() - self.herd_manager = None - self.field_manager = None - self.feed_manager = None - self.manure_manager = None self.simulation_type = simulation_type self.simulate_animals = self.simulation_type.simulate_animals self.simulate_fields = self.simulation_type.simulate_fields @@ -212,7 +208,7 @@ def _initialize_simulation(self) -> None: ) # if self.simulate_manure: - # TODO need to isolate manure request fulfillment so field operations won't need full Manure module + # TODO issue #2765 need to isolate manure request fulfillment so field operations won't need full Manure module self.manure_manager: ManureManager = ManureManager( self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude ) @@ -237,7 +233,8 @@ def simulate(self) -> None: self.herd_manager.cow_events_by_id, ) - ManureExcretionCalculator.emit_dmi_below_min_summary(info_map) + if self.simulate_manure: + ManureExcretionCalculator.emit_dmi_below_min_summary(info_map) EEEManager.estimate_all() t_end_sim = timer.time() From 5fc88b646ca89cadba67c5d6d6f23da318da2352 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 16:35:00 +0000 Subject: [PATCH 11/16] Apply Black Formatting From 45bdbcc99ba060d191eff86aac6931a763b2936c Mon Sep 17 00:00:00 2001 From: ew3361zh Date: Fri, 10 Apr 2026 16:39:15 +0000 Subject: [PATCH 12/16] Update badges on README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 828545d2cc..1bda88ff66 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![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-1274%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Mypy](https://img.shields.io/badge/Mypy-1218%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems From fdc3cf9dced4c40693451815e299777774f01e5f Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:40:27 -0400 Subject: [PATCH 13/16] use attribute not full module for boolean check --- RUFAS/simulation_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 9f4635c94b..37263d06a4 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -445,7 +445,7 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic requested_feed = self.herd_manager.collect_daily_feed_request() is_ok_to_feed_animals, daily_feeds_fed = ( self.feed_manager.manage_daily_feed_request(requested_feed, self.time) - if self.feed_manager is not None + if self.simulate_feed else True ), FeedFulfillmentResults.fulfill_feed_request_as_purchased(requested_feed) From a72c7ba96b77004f859312dc88c7680c13305239 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 16:42:24 +0000 Subject: [PATCH 14/16] Apply Black Formatting --- RUFAS/simulation_engine.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 37263d06a4..b2273d17bb 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -444,9 +444,7 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic """ requested_feed = self.herd_manager.collect_daily_feed_request() is_ok_to_feed_animals, daily_feeds_fed = ( - self.feed_manager.manage_daily_feed_request(requested_feed, self.time) - if self.simulate_feed - else True + self.feed_manager.manage_daily_feed_request(requested_feed, self.time) if self.simulate_feed else True ), FeedFulfillmentResults.fulfill_feed_request_as_purchased(requested_feed) daily_purchased_feeds_fed = daily_feeds_fed.purchased From 591e51d62177f8f1d649888888954c8486e9d871 Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:00:52 -0400 Subject: [PATCH 15/16] moves field manure supplier into data structures --- RUFAS/biophysical/manure/manure_manager.py | 47 +++++++++---------- .../field_manure_supplier.py | 0 RUFAS/simulation_engine.py | 42 +++++++++-------- .../test_manure/test_field_manure_supplier.py | 2 +- .../test_manure_manager.py | 2 +- 5 files changed, 45 insertions(+), 48 deletions(-) rename RUFAS/{biophysical/manure => data_structures}/field_manure_supplier.py (100%) diff --git a/RUFAS/biophysical/manure/manure_manager.py b/RUFAS/biophysical/manure/manure_manager.py index c511ddbbdb..aed106e971 100644 --- a/RUFAS/biophysical/manure/manure_manager.py +++ b/RUFAS/biophysical/manure/manure_manager.py @@ -3,7 +3,7 @@ from dataclasses import replace from typing import Any -from RUFAS.biophysical.manure.field_manure_supplier import FieldManureSupplier +from RUFAS.data_structures.field_manure_supplier import FieldManureSupplier from RUFAS.biophysical.manure.handler.handler import Handler from RUFAS.biophysical.manure.manure_nutrient_manager import ManureNutrientManager from RUFAS.biophysical.manure.processor import Processor @@ -820,7 +820,7 @@ def _generate_adjacency_matrix_keys(self) -> list[str]: return result_row_names def request_nutrients( - self, request: NutrientRequest, simulate_animals: bool, time: RufasTime + self, request: NutrientRequest, time: RufasTime ) -> NutrientRequestResults: """ Handle the request for specific nutrients from the crop and soil module. @@ -841,8 +841,6 @@ def request_nutrients( ---------- request : NutrientRequest The specific nutrient request, including quantities of nitrogen and phosphorus. - simulate_animals : bool - Indicates whether animals are being simulated. time : RufasTime The current time in the simulation. @@ -855,29 +853,26 @@ def request_nutrients( Returns None if the request cannot be fulfilled. """ - if simulate_animals: - request_result, is_nutrient_request_fulfilled = self._manure_nutrient_manager.handle_nutrient_request( - request + request_result, is_nutrient_request_fulfilled = self._manure_nutrient_manager.handle_nutrient_request( + request + ) + 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( + "Supplemental manure needed", + "Attempting to fulfill manure nutrient request shortfall with supplemental manure.", + {"class": self.__class__.__name__, "function": self.request_nutrients.__name__}, ) - 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( - "Supplemental manure needed", - "Attempting to fulfill manure nutrient request shortfall with supplemental manure.", - {"class": self.__class__.__name__, "function": self.request_nutrients.__name__}, - ) - amount_supplemental_manure_needed = self._calculate_supplemental_manure_needed(request_result, request) - supplemental_manure = FieldManureSupplier.request_nutrients(amount_supplemental_manure_needed) - self._record_manure_request_results(supplemental_manure, "off_farm_manure", time) - if request_result is None: - return supplemental_manure - return request_result + supplemental_manure - return request_result - else: - return FieldManureSupplier.request_nutrients(request) + amount_supplemental_manure_needed = self._calculate_supplemental_manure_needed(request_result, request) + supplemental_manure = FieldManureSupplier.request_nutrients(amount_supplemental_manure_needed) + self._record_manure_request_results(supplemental_manure, "off_farm_manure", time) + if request_result is None: + return supplemental_manure + return request_result + supplemental_manure + return request_result def _remove_nutrients_from_storage(self, results: NutrientRequestResults, manure_type: ManureType) -> None: """ diff --git a/RUFAS/biophysical/manure/field_manure_supplier.py b/RUFAS/data_structures/field_manure_supplier.py similarity index 100% rename from RUFAS/biophysical/manure/field_manure_supplier.py rename to RUFAS/data_structures/field_manure_supplier.py diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index b2273d17bb..1057a60e37 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -18,6 +18,7 @@ IdealFeeds, NutrientStandard, ) +from RUFAS.data_structures.field_manure_supplier import FieldManureSupplier from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -207,11 +208,10 @@ def _initialize_simulation(self) -> None: simulate_animals=self.simulate_animals, ) - # if self.simulate_manure: - # TODO issue #2765 need to isolate manure request fulfillment so field operations won't need full Manure module - self.manure_manager: ManureManager = ManureManager( - self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude - ) + if self.simulate_manure: + self.manure_manager: ManureManager = ManureManager( + self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude + ) def simulate(self) -> None: """Executes the simulation.""" @@ -268,6 +268,7 @@ def _execute_full_farm_daily_simulation(self) -> None: """ daily_harvested_crops = self._execute_daily_field_operations() + self._receive_daily_harvested_crops(daily_harvested_crops) harvest_schedule = self._build_harvest_schedule(daily_harvested_crops) self._execute_feed_planning(harvest_schedule) @@ -298,6 +299,7 @@ def _execute_field_and_feed_daily_simulation(self) -> None: daily_harvested_crops = self._execute_daily_field_operations() harvest_schedule = self._build_harvest_schedule(daily_harvested_crops) + self._execute_feed_planning(harvest_schedule) self._report_daily_records() @@ -311,11 +313,20 @@ def _execute_daily_field_operations(self) -> list[HarvestedCrop]: self.weather, self.time, manure_applications ) - # TODO move to a feed-related action rather than field? + return harvested_crops + + def _receive_daily_harvested_crops(self, harvested_crops: list[HarvestedCrop]) -> None: + """Receives and stores the crops harvested.""" for crop in harvested_crops: self.feed_manager.receive_crop(crop, self.time.simulation_day) - return harvested_crops + if self._should_recalculate_feed_planning: + harvest_schedule_crops = set(crop.config_name for crop in harvested_crops) + crops_to_get_next_harvest_dates = [ + crop for crop in self.feed_manager.crop_to_rufas_id.keys() if crop not in harvest_schedule_crops + ] + harvest_schedule_crops = harvest_schedule_crops.union(crops_to_get_next_harvest_dates) + self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequestResults]: """Requests nutrients from the manure manager for each field in the simulation. @@ -334,10 +345,10 @@ def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequest manure_request = manure_event_request.nutrient_request manure_request_results = None if manure_request is not None: - # TODO figure out how to generate manure request with no manure module. - manure_request_results = self.manure_manager.request_nutrients( - manure_request, self.simulate_animals, self.time - ) + if self.simulate_manure: + manure_request_results = self.manure_manager.request_nutrients(manure_request, self.time) + else: + manure_request_results = FieldManureSupplier.request_nutrients(manure_request) manure_applications.append(ManureEventNutrientRequestResults(field_name, event, manure_request_results)) return manure_applications @@ -364,15 +375,6 @@ def _build_harvest_schedule(self, harvested_crops: list[HarvestedCrop]) -> dict[ even if they were not harvested today. """ harvest_schedule_crops = set(crop.config_name for crop in harvested_crops) - - if self._should_recalculate_feed_planning: - # TODO this needs to be removed from here because it's a feed_manager operation - crops_to_get_next_harvest_dates = [ - crop for crop in self.feed_manager.crop_to_rufas_id.keys() if crop not in harvest_schedule_crops - ] - harvest_schedule_crops = harvest_schedule_crops.union(crops_to_get_next_harvest_dates) - self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval - harvest_schedule = self.field_manager.get_next_harvest_dates(list(harvest_schedule_crops)) return harvest_schedule diff --git a/tests/test_biophysical/test_manure/test_field_manure_supplier.py b/tests/test_biophysical/test_manure/test_field_manure_supplier.py index 302e45ed21..3a41b8ccac 100644 --- a/tests/test_biophysical/test_manure/test_field_manure_supplier.py +++ b/tests/test_biophysical/test_manure/test_field_manure_supplier.py @@ -1,6 +1,6 @@ import pytest -from RUFAS.biophysical.manure.field_manure_supplier import FieldManureSupplier +from RUFAS.data_structures.field_manure_supplier import FieldManureSupplier from RUFAS.data_structures.manure_to_crop_soil_connection import NutrientRequest, NutrientRequestResults from RUFAS.data_structures.manure_types import ManureType 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..61190a9c98 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 @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture, MockFixture from RUFAS.biophysical.manure.digester.digester import Digester -from RUFAS.biophysical.manure.field_manure_supplier import FieldManureSupplier +from RUFAS.data_structures.field_manure_supplier import FieldManureSupplier from RUFAS.biophysical.manure.manure_manager import STORAGE_CLASS_TO_TYPE, ManureManager from RUFAS.biophysical.manure.manure_nutrient_manager import ManureNutrientManager from RUFAS.biophysical.manure.processor import Processor From 9e2a7c88d4aea03f88bc4c601f05aa87b348e3da Mon Sep 17 00:00:00 2001 From: Niko <70217952+ew3361zh@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:55:06 -0400 Subject: [PATCH 16/16] moved update all max daily feeds to wrapper function to be called with or without feed module --- RUFAS/simulation_engine.py | 78 ++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 1057a60e37..51e06c3814 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -13,10 +13,12 @@ from RUFAS.data_structures.animal_to_manure_connection import ManureStream from RUFAS.data_structures.crop_soil_to_feed_storage_connection import HarvestedCrop from RUFAS.data_structures.feed_storage_to_animal_connection import ( + RUFAS_ID, AvailableFeedsBuilder, FeedFulfillmentResults, IdealFeeds, NutrientStandard, + TotalInventory, ) from RUFAS.data_structures.field_manure_supplier import FieldManureSupplier from RUFAS.data_structures.manure_to_crop_soil_connection import ManureEventNutrientRequestResults @@ -268,9 +270,10 @@ def _execute_full_farm_daily_simulation(self) -> None: """ daily_harvested_crops = self._execute_daily_field_operations() - self._receive_daily_harvested_crops(daily_harvested_crops) + self._receive_daily_harvested_crops(daily_harvested_crops) harvest_schedule = self._build_harvest_schedule(daily_harvested_crops) + self._execute_feed_planning(harvest_schedule) self._execute_ration_planning() @@ -298,6 +301,7 @@ def _execute_field_and_feed_daily_simulation(self) -> None: """ daily_harvested_crops = self._execute_daily_field_operations() + self._receive_daily_harvested_crops(daily_harvested_crops) harvest_schedule = self._build_harvest_schedule(daily_harvested_crops) self._execute_feed_planning(harvest_schedule) @@ -315,19 +319,6 @@ def _execute_daily_field_operations(self) -> list[HarvestedCrop]: return harvested_crops - def _receive_daily_harvested_crops(self, harvested_crops: list[HarvestedCrop]) -> None: - """Receives and stores the crops harvested.""" - for crop in harvested_crops: - self.feed_manager.receive_crop(crop, self.time.simulation_day) - - if self._should_recalculate_feed_planning: - harvest_schedule_crops = set(crop.config_name for crop in harvested_crops) - crops_to_get_next_harvest_dates = [ - crop for crop in self.feed_manager.crop_to_rufas_id.keys() if crop not in harvest_schedule_crops - ] - harvest_schedule_crops = harvest_schedule_crops.union(crops_to_get_next_harvest_dates) - self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval - def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequestResults]: """Requests nutrients from the manure manager for each field in the simulation. @@ -352,6 +343,20 @@ def _generate_daily_manure_applications(self) -> list[ManureEventNutrientRequest manure_applications.append(ManureEventNutrientRequestResults(field_name, event, manure_request_results)) return manure_applications + def _receive_daily_harvested_crops(self, harvested_crops: list[HarvestedCrop]) -> None: + """Receives and stores the crops harvested.""" + for crop in harvested_crops: + self.feed_manager.receive_crop(crop, self.time.simulation_day) + + if self._should_recalculate_feed_planning: + harvest_schedule_crops = set(crop.config_name for crop in harvested_crops) + crops_to_get_next_harvest_dates = [ + crop for crop in self.feed_manager.crop_to_rufas_id.keys() if crop not in harvest_schedule_crops + ] + harvest_schedule_crops = harvest_schedule_crops.union(crops_to_get_next_harvest_dates) + # TODO figure out where this goes and when it should happen in Animals-only simulation + self.next_max_daily_feed_recalculation = self.time.current_date + self.max_daily_feed_recalculation_interval + def _build_harvest_schedule(self, harvested_crops: list[HarvestedCrop]) -> dict[str, date | None]: """ Builds a schedule of next harvest dates for all crop types. @@ -404,9 +409,7 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No ideal_feeds_to_purchase = ( IdealFeeds({}) if not self.simulate_animals - else self.herd_manager.update_all_max_daily_feeds( - total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time - ) + 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.feed_manager.report_feed_storage_levels(self.time.simulation_day, "daily_storage_levels") @@ -416,6 +419,17 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No self.next_degradations_processing = (self.time.current_date + self.feed_degradations_interval_length).date() self.feed_manager.process_degradations(self.weather, self.time) + def _update_all_max_daily_feeds( + self, + total_projected_inventory: TotalInventory, + next_harvest_dates_with_rufas_ids: dict[RUFAS_ID, date], + ) -> IdealFeeds: + """Wrapper function for HerdManager daily max feeds update.""" + # TODO figure out what data to send to this function and where to call it if there is no feed module. + return self.herd_manager.update_all_max_daily_feeds( + total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time + ) + @property def _is_time_to_process_feed_degradations(self) -> bool: """Checks if it's time to process feed degradations""" @@ -432,6 +446,21 @@ def _is_time_to_reformulate_ration(self) -> bool: """Checks if it's time to reformulate the ration based on the user-defined interval.""" return self.time.current_date.date() >= self.next_ration_reformulation + def _formulate_ration(self) -> None: + """Formulates the ration for the animals.""" + current_temperature = self.weather.get_current_day_conditions(time=self.time).mean_air_temperature + requested_feed = self.herd_manager.formulate_rations( + self.available_feeds, + current_temperature, + self.ration_formulation_interval_length.days, + self.time.simulation_day, + ) + if self.simulate_feed: + self.feed_manager.manage_ration_interval_purchases(requested_feed, self.time) + self.feed_manager.report_feed_manager_balance(self.time.simulation_day) + + self.herd_manager.report_ration_interval_data(self.time.simulation_day) + def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dict[int, float]]: """ Executes the daily animal routines. @@ -464,21 +493,6 @@ def _execute_daily_animal_operations(self) -> tuple[dict[str, ManureStream], dic return all_manure_data, daily_purchased_feeds_fed - def _formulate_ration(self) -> None: - """Formulates the ration for the animals.""" - current_temperature = self.weather.get_current_day_conditions(time=self.time).mean_air_temperature - requested_feed = self.herd_manager.formulate_rations( - self.available_feeds, - current_temperature, - self.ration_formulation_interval_length.days, - self.time.simulation_day, - ) - if self.simulate_feed: - self.feed_manager.manage_ration_interval_purchases(requested_feed, self.time) - self.feed_manager.report_feed_manager_balance(self.time.simulation_day) - - self.herd_manager.report_ration_interval_data(self.time.simulation_day) - def _execute_daily_manure_operations(self, daily_manure_data: dict[str, ManureStream] | None) -> None: """ Executes the daily manure operations routine.