diff --git a/README.md b/README.md index 2024cc3be2..597cf307da 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -[![Flake8](https://img.shields.io/badge/Flake8-passed-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) -[![Pytest](https://img.shields.io/badge/Pytest-passed-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) -[![Coverage](https://img.shields.io/badge/Coverage-99%25-brightgreen)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) -[![Mypy](https://img.shields.io/badge/Mypy-1180%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Flake8](https://img.shields.io/badge/Flake8-failed-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Pytest](https://img.shields.io/badge/Pytest-failed-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Coverage](https://img.shields.io/badge/Coverage-%25-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) +[![Mypy](https://img.shields.io/badge/Mypy-1209%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems diff --git a/RUFAS/biophysical/animal/herd_manager.py b/RUFAS/biophysical/animal/herd_manager.py index c0e3c1fc43..972e5c7299 100644 --- a/RUFAS/biophysical/animal/herd_manager.py +++ b/RUFAS/biophysical/animal/herd_manager.py @@ -551,25 +551,21 @@ 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 - ) - self._handle_newly_added_animals( - newborn_calves, available_feeds, current_day_conditions, total_inventory, simulation_day - ) - self._handle_newly_added_animals( - newly_added_animals, available_feeds, current_day_conditions, total_inventory, 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, 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. @@ -586,8 +582,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 ------- @@ -669,7 +663,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, ) else: @@ -680,7 +673,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, ) @@ -945,7 +937,6 @@ def _handle_graduated_animals( graduated_animals: list[Animal], available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -959,8 +950,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. @@ -968,16 +957,13 @@ 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, total_inventory, simulation_day - ) + self._add_animal_to_pen_and_id_map(animal, available_feeds, current_day_conditions, simulation_day) def _handle_newly_added_animals( self, new_animals: list[Animal], available_feeds: list[Feed], current_day_conditions: CurrentDayConditions, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -991,16 +977,12 @@ 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 - ) + 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: @@ -1023,7 +1005,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: """ @@ -1037,8 +1018,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. @@ -1067,7 +1046,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, ) @@ -1487,8 +1465,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) @@ -1531,7 +1507,6 @@ def formulate_rations( available_feeds: list[Feed], current_temperature: float, ration_interval_length: int, - total_inventory: TotalInventory, simulation_day: int, ) -> RequestedFeed: """ @@ -1545,8 +1520,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. @@ -1571,9 +1544,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, total_inventory, 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 @@ -1582,7 +1553,6 @@ def _reformulate_ration_single_pen( pen: Pen, pen_available_feeds: list[Feed], current_temperature: float, - total_inventory: TotalInventory, simulation_day: int, ) -> None: """ @@ -1596,8 +1566,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. @@ -1620,7 +1588,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 9759b6ebbf..4b26e3387a 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, - NASEMFeed, - NRCFeed, - NutrientStandard, + FeedFulfillmentResults, PlanningCycleAllowance, RuntimePurchaseAllowance, RequestedFeed, @@ -21,19 +17,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"] @@ -47,8 +38,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] @@ -87,12 +76,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) @@ -628,7 +617,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 +631,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 +649,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 +663,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 +686,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, @@ -870,78 +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 - - 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/biophysical/manure/manure_manager.py b/RUFAS/biophysical/manure/manure_manager.py index c511ddbbdb..32819e9c58 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 @@ -819,9 +819,7 @@ def _generate_adjacency_matrix_keys(self) -> list[str]: result_row_names.append(row_name) return result_row_names - def request_nutrients( - self, request: NutrientRequest, simulate_animals: bool, time: RufasTime - ) -> NutrientRequestResults: + def request_nutrients(self, request: NutrientRequest, time: RufasTime) -> NutrientRequestResults: """ Handle the request for specific nutrients from the crop and soil module. This method evaluates the nutrient request made by considering both nitrogen and phosphorus @@ -841,8 +839,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 +851,24 @@ 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/data_structures/feed_storage_to_animal_connection.py b/RUFAS/data_structures/feed_storage_to_animal_connection.py index 861de5c0c5..22934e7332 100644 --- a/RUFAS/data_structures/feed_storage_to_animal_connection.py +++ b/RUFAS/data_structures/feed_storage_to_animal_connection.py @@ -1,9 +1,12 @@ from collections import defaultdict -from dataclasses import dataclass +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,92 @@ 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: """ @@ -395,6 +487,54 @@ 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/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 5aa6c52213..a044de4666 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -12,7 +12,15 @@ 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 ( + 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 from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager @@ -21,6 +29,8 @@ from RUFAS.rufas_time import RufasTime from RUFAS.weather import Weather +DEFAULT_FEED_DEGRADATIONS_PROCESSING_INTERVAL = 30 + class SimulationType(Enum): """ @@ -40,15 +50,47 @@ 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 def get_simulation_type(cls, simulation_type: str) -> "SimulationType": """ @@ -107,6 +149,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, @@ -116,49 +161,59 @@ 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, - ) - - self.manure_manager: ManureManager = ManureManager( - self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude - ) + if self.simulate_fields: + self.field_manager: FieldManager = FieldManager() + + 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")) + 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, + self.available_feeds, + feed_storage_configs, + feed_storage_instances, + ) + 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( + 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_degradations_processing = self.time.current_date.date() + + 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, + available_feeds=self.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.""" @@ -171,16 +226,19 @@ def simulate(self) -> None: self._run_simulation_main_loop() - ManureExcretionCalculator.emit_dmi_below_min_summary(info_map) + 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, + self.herd_manager.animal_genetic_history_by_id, + ) + + if self.simulate_manure: + ManureExcretionCalculator.emit_dmi_below_min_summary(info_map) - 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, - self.herd_manager.animal_genetic_history_by_id, - ) EEEManager.estimate_all() t_end_sim = timer.time() @@ -214,7 +272,9 @@ 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) self._execute_ration_planning() @@ -242,10 +302,10 @@ 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) - self._execute_ration_planning() + self._execute_feed_planning(harvest_schedule) self._report_daily_records() @@ -253,17 +313,14 @@ 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 ) - 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 @@ -280,12 +337,27 @@ 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: - 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 + 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. @@ -309,14 +381,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: - 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 @@ -343,14 +407,39 @@ 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( - total_projected_inventory, next_harvest_dates_with_rufas_ids, self.time + ideal_feeds_to_purchase = ( + IdealFeeds({}) + if not self.simulate_animals + 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") + self.feed_manager.report_cumulative_purchased_feeds(self.time.simulation_day) + + 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) + + 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""" + 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.""" if self._is_time_to_reformulate_ration: + self.next_ration_reformulation = (self.time.current_date + self.ration_formulation_interval_length).date() self._formulate_ration() @property @@ -358,6 +447,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. @@ -371,48 +475,25 @@ 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.simulate_feed 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__} 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 _formulate_ration(self) -> None: - """Formulates the ration for the animals.""" - 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 - ) - 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, - 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) - - self.feed_manager.report_feed_manager_balance(self.time.simulation_day) - def _execute_daily_manure_operations(self, daily_manure_data: dict[str, ManureStream] | None) -> None: """ Executes the daily manure operations routine. 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 diff --git a/tests/test_simulation_engine.py b/tests/test_simulation_engine.py index 3b1e626aad..8da24a6064 100644 --- a/tests/test_simulation_engine.py +++ b/tests/test_simulation_engine.py @@ -314,7 +314,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, ) @@ -358,7 +358,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, ) @@ -994,7 +994,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),