From 9e16c60f8a984e3b2cee6eedd6f367498ef7363d Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Fri, 31 Jul 2026 16:26:06 +0900 Subject: [PATCH 1/6] Add feed only simulation type Adds a `feed_only` SimulationType that runs the Feed Storage module without the crop and soil, animal, or manure modules. Feed storages are given their starting contents through a new optional initial feed storage contents input, since there is no crop and soil module to harvest anything into them. Contents are stocked through `Storage.receive_crop`, so capacity checks and arrival losses apply. Extracts `_execute_feed_storage_upkeep` from `_execute_feed_planning` so storage reporting and degradations can run on days that feed planning does not. Call order is unchanged for existing simulation types. --- .../biophysical/feed_storage/feed_manager.py | 34 +++ RUFAS/biophysical/feed_storage/storage.py | 38 +++ RUFAS/input/metadata/properties/default.json | 85 ++++++- RUFAS/simulation_engine.py | 86 ++++++- .../data/config/example_feed_only_config.json | 9 + .../example_feed_only_initial_contents.json | 60 +++++ .../tasks/available_simulation_tasks.json | 8 + input/data/tasks/example_feed_only_task.json | 15 ++ .../metadata/example_feed_only_metadata.json | 219 ++++++++++++++++++ .../test_feed_storage/test_feed_manager.py | 30 +++ .../test_feed_storage/test_storage.py | 55 +++++ tests/test_simulation_engine.py | 148 ++++++++++++ 12 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 input/data/config/example_feed_only_config.json create mode 100644 input/data/feed_management/example_feed_only_initial_contents.json create mode 100644 input/data/tasks/example_feed_only_task.json create mode 100644 input/metadata/example_feed_only_metadata.json diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index 38161681d9..16b829e148 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -300,6 +300,40 @@ def translate_crop_config_name_to_rufas_id( next_harvest_dates_rufas_ids[self.crop_to_rufas_id[crop_config]] = harvest_date return next_harvest_dates_rufas_ids + def stock_initial_storage_contents( + self, initial_contents_by_storage_name: dict[str, dict[str, float]], time: RufasTime + ) -> None: + """ + Stocks storages with the initial contents specified for them, if any. + + Parameters + ---------- + initial_contents_by_storage_name : dict[str, dict[str, float]] + The dry matter mass and composition each storage holds when the simulation starts, keyed by storage + instance name. Storages that are not named start empty. + time : RufasTime + RufasTime instance containing the current time of the simulation. This should be called at the start of + the simulation, before any daily routines run. + + """ + info_map = { + "class": self.__class__.__name__, + "function": self.stock_initial_storage_contents.__name__, + } + storage_time = time.current_date.date() + for storage_name, initial_contents in initial_contents_by_storage_name.items(): + if storage_name not in self.active_storages: + self._om.add_warning( + "Unknown storage in initial feed storage contents", + f"Storage '{storage_name}' has initial contents specified, but no active storage with that name " + "exists. These contents will not be stocked.", + info_map, + ) + continue + self.active_storages[storage_name].stock_initial_contents( + initial_contents, storage_time, time.simulation_day + ) + def receive_crop( self, harvested_crop: HarvestedCrop, diff --git a/RUFAS/biophysical/feed_storage/storage.py b/RUFAS/biophysical/feed_storage/storage.py index 40127ff13c..22814ad83e 100644 --- a/RUFAS/biophysical/feed_storage/storage.py +++ b/RUFAS/biophysical/feed_storage/storage.py @@ -178,6 +178,44 @@ def receive_crop(self, crop: HarvestedCrop, simulation_day: int) -> None: crop.remove_dry_matter_mass(dry_matter_to_remove) self._record_stored_crops(simulation_day + initial_degradation_day_offset) + def stock_initial_contents( + self, initial_contents: dict[str, float], storage_time: date, simulation_day: int + ) -> None: + """ + Stocks this storage with the specified initial contents. + + Parameters + ---------- + initial_contents : dict[str, float] + The dry matter mass and composition of the crop this storage holds when the simulation starts, keyed by + the ``HarvestedCrop`` field names. + storage_time : date + The date the initial contents are considered to have been stored. This should be the start date of the + simulation. + simulation_day : int + The current simulation day, used for record keeping. + + Notes + ----- + Initial contents let a storage begin the simulation already holding a crop, which is how the feed module is + given something to manage when there is no crop and soil module to harvest anything. The mass and composition + of the contents are stated in the initial feed storage contents input because there is nothing to derive them + from without harvests. + + The contents are received through ``receive_crop``, so capacity checks and arrival losses apply to them the + same way they would to a harvested crop arriving on the start date. + + """ + composition: dict[str, Any] = dict(initial_contents) + crop = HarvestedCrop( + config_name=self.crop_name, + field_name=self.field_names[0] if self.field_names else self.storage_name, + harvest_time=storage_time, + storage_time=storage_time, + **composition, + ) + self.receive_crop(crop, simulation_day) + def process_degradations(self, weather: Weather, time: RufasTime) -> None: """ Processes the degradations and losses of nutrients and dry matter in the stored crops. diff --git a/RUFAS/input/metadata/properties/default.json b/RUFAS/input/metadata/properties/default.json index 7432add8e1..1b4ec1ef41 100644 --- a/RUFAS/input/metadata/properties/default.json +++ b/RUFAS/input/metadata/properties/default.json @@ -17,7 +17,7 @@ "type": "string", "description": "The type of daily simulation to run. Will determine daily simulation function in SimulationEngine.", "default": "full_farm", - "pattern": "^(full_farm|field_and_feed|field_only|animals_only)$" + "pattern": "^(full_farm|field_and_feed|field_only|animals_only|feed_only)$" }, "nutrient_standard": { "type": "string", @@ -6900,5 +6900,86 @@ "type": "number" } } + }, + "initial_feed_storage_contents_properties": { + "data_collection_app_compatible": false, + "initial_contents": { + "type": "array", + "description": "The contents that feed storages hold when the simulation starts. Storages that are not named start empty. Stated directly because a simulation without the crop and soil module has no harvests to derive contents from.", + "minimum_length": 0, + "properties": { + "type": "object", + "description": "The dry matter mass and composition of the crop one storage holds when the simulation starts.", + "storage_name": { + "type": "string", + "description": "Name of the feed storage instance that holds these contents. Must match a name from the feed storage configurations." + }, + "dry_matter_mass": { + "type": "number", + "description": "Mass of dry matter initially held in the storage (kg).", + "minimum": 0.0 + }, + "dry_matter_percentage": { + "type": "number", + "description": "Percent of the initially held mass that is not water.", + "minimum": 0.0, + "maximum": 100.0 + }, + "dry_matter_digestibility": { + "type": "number", + "description": "Percent of the initially held mass that is digestible.", + "minimum": 0.0, + "maximum": 100.0 + }, + "crude_protein_percent": { + "type": "number", + "description": "Percent of the initially held mass that is dietary crude protein.", + "minimum": 0.0, + "maximum": 100.0 + }, + "non_protein_nitrogen": { + "type": "number", + "description": "Percent of the initially held mass that is non-protein nitrogen.", + "minimum": 0.0, + "maximum": 100.0 + }, + "starch": { + "type": "number", + "description": "Percent of the initially held mass that is starch.", + "minimum": 0.0, + "maximum": 100.0 + }, + "adf": { + "type": "number", + "description": "Percent of the initially held mass that is acid detergent fiber.", + "minimum": 0.0, + "maximum": 100.0 + }, + "ndf": { + "type": "number", + "description": "Percent of the initially held mass that is neutral detergent fiber.", + "minimum": 0.0, + "maximum": 100.0 + }, + "lignin": { + "type": "number", + "description": "Percent of the initially held mass that is lignin.", + "minimum": 0.0, + "maximum": 100.0 + }, + "sugar": { + "type": "number", + "description": "Percent of the initially held mass that is labile carbohydrate.", + "minimum": 0.0, + "maximum": 100.0 + }, + "ash": { + "type": "number", + "description": "Percent of the initially held mass that is ash.", + "minimum": 0.0, + "maximum": 100.0 + } + } + } } -} +} \ No newline at end of file diff --git a/RUFAS/simulation_engine.py b/RUFAS/simulation_engine.py index 8992e69d9b..fcaffff87f 100644 --- a/RUFAS/simulation_engine.py +++ b/RUFAS/simulation_engine.py @@ -51,6 +51,7 @@ class SimulationType(Enum): FIELD_AND_FEED = "field_and_feed" FIELD_ONLY = "field_only" ANIMALS_ONLY = "animals_only" + FEED_ONLY = "feed_only" @property def simulate_animals(self) -> bool: @@ -95,7 +96,7 @@ def _fields_simulation_types(cls) -> set["SimulationType"]: @classmethod def _feed_simulation_types(cls) -> set["SimulationType"]: """Return the set of simulation types that simulate feed storage and management.""" - return {cls.FULL_FARM, cls.FIELD_AND_FEED} + return {cls.FULL_FARM, cls.FIELD_AND_FEED, cls.FEED_ONLY} @classmethod def get_simulation_type(cls, simulation_type: str) -> "SimulationType": @@ -196,6 +197,7 @@ def __init__(self, simulation_type: SimulationType) -> None: SimulationType.FIELD_AND_FEED: self._execute_field_and_feed_daily_simulation, SimulationType.FIELD_ONLY: self._execute_field_only_simulation, SimulationType.ANIMALS_ONLY: self._execute_animals_only_daily_simulation, + SimulationType.FEED_ONLY: self._execute_feed_only_daily_simulation, } self._setup_simulation_modules() @@ -232,6 +234,7 @@ def _setup_simulation_modules(self) -> None: feed_storage_configs, feed_storage_instances, ) + self.feed_manager.stock_initial_storage_contents(self._gather_initial_feed_storage_contents(), self.time) feed_manager_available_feed_ids = [feed.rufas_id for feed in self.available_feeds] self.emissions_estimator.check_available_purchased_feed_data(feed_manager_available_feed_ids) max_daily_feed_recalculations_per_year: int = feeds_config["ration_formulation_parameters"][ @@ -263,6 +266,51 @@ def _setup_simulation_modules(self) -> None: self.weather.intercept_mean_temp, self.weather.phase_shift, self.weather.amplitude ) + def _gather_initial_feed_storage_contents(self) -> dict[str, dict[str, float]]: + """ + Gathers the initial contents that feed storages hold when the simulation starts. + + All initial feed storage contents input files are combined. Logs a warning if fields are not simulated and no + initial contents input files are found, since without harvests the feed storages would then stay empty for the + whole simulation. + + Returns + ------- + dict[str, dict[str, float]] + The dry matter mass and composition each storage holds when the simulation starts, keyed by storage + instance name. Empty if no initial contents inputs are provided. + """ + info_map = { + "class": SimulationEngine.__name__, + "function": SimulationEngine._gather_initial_feed_storage_contents.__name__, + } + initial_contents_names: list[str] = self.im.get_data_keys_by_properties( + "initial_feed_storage_contents_properties" + ) + if not initial_contents_names and not self.simulate_fields: + self.om.add_warning( + "No initial feed storage contents input files.", + "All feed storages will start the simulation empty, and there are no fields to harvest crops from.", + info_map, + ) + + initial_contents_by_storage_name: dict[str, dict[str, float]] = {} + for initial_contents_name in initial_contents_names: + initial_contents_data: dict[str, list[dict[str, Any]]] = self.im.get_data(initial_contents_name) + for storage_contents in initial_contents_data["initial_contents"]: + storage_contents = dict(storage_contents) + storage_name = str(storage_contents.pop("storage_name")) + if storage_name in initial_contents_by_storage_name: + self.om.add_warning( + "Duplicate storage in initial feed storage contents", + f"Initial contents for storage '{storage_name}' are specified more than once. Only the first " + "specification will be stocked.", + info_map, + ) + continue + initial_contents_by_storage_name[storage_name] = storage_contents + return initial_contents_by_storage_name + def _gather_field_data(self) -> dict[str, dict[str, Any]]: """ Gathers configuration data for all fields from the InputManager. @@ -428,6 +476,29 @@ def _execute_animals_only_daily_simulation(self) -> None: self._advance_time() + def _execute_feed_only_daily_simulation(self) -> None: + """ + Executes the daily simulation routines for a farm with only the feed module. + + Daily Feed Only Simulation Process: + 1. Feed storage upkeep (report storage levels, process degradations) + 2. Record keeping (time, weather) + 3. Advance simulation date + + Notes + ----- + The feed storages start the simulation holding the initial feed storage contents given in the inputs, and no + crops arrive after that: there is no crop and soil module to harvest any. Feed planning is not run either. + Without animals there is no feed demand to plan against, so the ideal feeds to hold for the planning cycle are + always empty and no feed is ever purchased. Nothing leaves storage other than through degradation. + + """ + self._execute_feed_storage_upkeep() + + self._report_daily_records() + + self._advance_time() + def _execute_daily_field_operations(self) -> list[HarvestedCrop]: """Handles daily field operations including manure applications and crop harvesting/receiving.""" manure_applications: list[ManureEventNutrientRequestResults] = self._generate_daily_manure_applications() @@ -531,6 +602,19 @@ def _execute_feed_planning(self, harvest_schedule: dict[str, date | None]) -> No else self._update_all_max_daily_feeds(total_projected_inventory, next_harvest_dates_with_rufas_ids) ) self.feed_manager.manage_planning_cycle_purchases(ideal_feeds_to_purchase, self.time) + + self._execute_feed_storage_upkeep() + + def _execute_feed_storage_upkeep(self) -> None: + """ + Reports the state of feed storage, and processes feed degradations if the degradation interval has elapsed. + + Notes + ----- + This is the part of feed management that does not depend on anything outside the feed module, so a feed only + simulation runs it on every simulated day rather than only on days that feed planning occurs. + + """ self.feed_manager.report_feed_storage_levels(self.time.simulation_day, "daily_storage_levels") self.feed_manager.report_cumulative_purchased_feeds(self.time.simulation_day) diff --git a/input/data/config/example_feed_only_config.json b/input/data/config/example_feed_only_config.json new file mode 100644 index 0000000000..fe11c0dc62 --- /dev/null +++ b/input/data/config/example_feed_only_config.json @@ -0,0 +1,9 @@ +{ + "start_date": "2013:1", + "end_date": "2019:365", + "set_seed": true, + "simulation_type": "feed_only", + "nutrient_standard": "NASEM", + "FIPS_county_code": 55025, + "include_detailed_values": false +} \ No newline at end of file diff --git a/input/data/feed_management/example_feed_only_initial_contents.json b/input/data/feed_management/example_feed_only_initial_contents.json new file mode 100644 index 0000000000..986d386b09 --- /dev/null +++ b/input/data/feed_management/example_feed_only_initial_contents.json @@ -0,0 +1,60 @@ +{ + "initial_contents": [ + { + "storage_name": "corn_silage_storage_1", + "dry_matter_mass": 200000.0, + "dry_matter_percentage": 35.0, + "dry_matter_digestibility": 40.0, + "crude_protein_percent": 7.707, + "non_protein_nitrogen": 4.55, + "starch": 31.0, + "adf": 24.0, + "ndf": 40.931, + "lignin": 3.054, + "sugar": 1.5, + "ash": 3.843 + }, + { + "storage_name": "alfalfa_silage_storage_1", + "dry_matter_mass": 105000.0, + "dry_matter_percentage": 35.0, + "dry_matter_digestibility": 40.0, + "crude_protein_percent": 20.471, + "non_protein_nitrogen": 10.098, + "starch": 1.973, + "adf": 33.683, + "ndf": 43.195, + "lignin": 7.419, + "sugar": 6.274, + "ash": 10.597 + }, + { + "storage_name": "corn_grain_storage_2", + "dry_matter_mass": 110000.0, + "dry_matter_percentage": 86.0907, + "dry_matter_digestibility": 40.0, + "crude_protein_percent": 8.514, + "non_protein_nitrogen": 1.0, + "starch": 72.0, + "adf": 3.5, + "ndf": 9.761, + "lignin": 1.37, + "sugar": 2.0, + "ash": 1.504 + }, + { + "storage_name": "alfalfa_hay_storage_2", + "dry_matter_mass": 30000.0, + "dry_matter_percentage": 88.136, + "dry_matter_digestibility": 40.0, + "crude_protein_percent": 20.745, + "non_protein_nitrogen": 10.2, + "starch": 2.0, + "adf": 33.0, + "ndf": 41.109, + "lignin": 6.643, + "sugar": 6.3, + "ash": 10.762 + } + ] +} \ No newline at end of file diff --git a/input/data/tasks/available_simulation_tasks.json b/input/data/tasks/available_simulation_tasks.json index 9344192fea..c84ecacd3b 100644 --- a/input/data/tasks/available_simulation_tasks.json +++ b/input/data/tasks/available_simulation_tasks.json @@ -52,6 +52,14 @@ "log_verbosity": "errors", "random_seed": 42, "cross_validation_file_paths": [] + }, + { + "task_type": "SIMULATION_SINGLE_RUN", + "metadata_file_path": "input/metadata/example_feed_only_metadata.json", + "output_prefix": "feed_only", + "log_verbosity": "errors", + "random_seed": 42, + "cross_validation_file_paths": [] } ] } \ No newline at end of file diff --git a/input/data/tasks/example_feed_only_task.json b/input/data/tasks/example_feed_only_task.json new file mode 100644 index 0000000000..f975033030 --- /dev/null +++ b/input/data/tasks/example_feed_only_task.json @@ -0,0 +1,15 @@ +{ + "parallel_workers": 4, + "tasks": [ + { + "task_type": "SIMULATION_SINGLE_RUN", + "metadata_file_path": "input/metadata/example_feed_only_metadata.json", + "output_prefix": "feed_only", + "log_verbosity": "errors", + "random_seed": 42, + "cross_validation_file_paths": [ + "input/metadata/cross_validation/weather_cross_validation.json" + ] + } + ] +} \ No newline at end of file diff --git a/input/metadata/example_feed_only_metadata.json b/input/metadata/example_feed_only_metadata.json new file mode 100644 index 0000000000..8c1d996d0b --- /dev/null +++ b/input/metadata/example_feed_only_metadata.json @@ -0,0 +1,219 @@ +{ + "files": { + "config": { + "title": "Config Data", + "description": "Configuration file for general simulation parameters.", + "path": "input/data/config/example_feed_only_config.json", + "type": "json", + "properties": "config_properties" + }, + "animal": { + "title": "Animal data", + "description": "Input data and configuration information for animal management decisions, herd attributes, and housing properties.", + "path": "input/data/animal/no_animal.json", + "type": "json", + "properties": "animal_properties" + }, + "animal_population": { + "title": "Animal population data", + "description": "Animal objects that herd initialization draws from during the simulation. A similar file can be generated using the -I and -s command line arguments simultaneously.", + "path": "input/data/animal/no_animal.json", + "type": "json", + "properties": "animal_population_properties" + }, + "animal_mean_phenotype": { + "title": "Mean animal phenotype data by birth year", + "description": "The mean animal phenotype data by birth year.", + "path": "input/data/animal_genetics/no_genetics.csv", + "type": "csv", + "properties": "animal_mean_phenotype_properties" + }, + "animal_top_listing_semen": { + "title": "Animal Top Listing Semen data", + "description": "The top listing semen value for new born calves.", + "path": "input/data/animal_genetics/no_genetics.csv", + "type": "csv", + "properties": "animal_top_listing_semen_properties" + }, + "lactation": { + "title": "Lactation curve adjustment values", + "description": "Values for the adjustment of the three wood parameters based on farm specific data", + "path": "input/data/animal/no_animal.json", + "type": "json", + "properties": "lactation_properties" + }, + "economy": { + "title": "Economy data", + "description": "Energy prices used in the EEE module.", + "path": "input/data/EEE/default_costs.csv", + "type": "csv", + "properties": "economic_properties" + }, + "emission": { + "title": "Emissions data", + "description": "General emission values used in the EEE module.", + "path": "input/data/EEE/default_emissions.csv", + "type": "csv", + "properties": "emissions_properties" + }, + "purchased_feeds_emissions": { + "title": "Emissions from purchased feeds", + "description": "Purchased feeds emission values used in the EEE module. Missing data interpolated following script in helpful_scripts/emissions_interpolation.", + "path": "input/data/EEE/full_feeds_emissions_July2024_interpolated_regional_average.csv", + "type": "csv", + "properties": "feed_emissions_properties" + }, + "purchased_feed_land_use_change_emissions": { + "title": "Land Use Change emissions from purchased feeds", + "description": "Purchased feeds land use change emission values used in the EEE module. Missing data interpolated following script in helpful_scripts/emissions_interpolation.", + "path": "input/data/EEE/full_feeds_land_use_change_emissions_July2024_interpolated_regional_average.csv", + "type": "csv", + "properties": "feed_emissions_properties" + }, + "feed": { + "title": "Feed data", + "description": "Feeds available for each animal combination, purchased feeds and their prices, feed storage options, and user-defined ration percentages and associated parameters.", + "path": "input/data/feed/example_Midwest_feed.json", + "type": "json", + "properties": "feed_properties" + }, + "NRC_Comp": { + "title": "NRC Comp data", + "description": "Nutritional information for each feed, following NRC (2001) guidelines.", + "path": "input/data/feed/NRC_comp.csv", + "type": "csv", + "properties": "NRC_Comp_properties" + }, + "NASEM_Comp": { + "title": "NASEM Comp data", + "description": "Nutritional information for each feed, following NASEM (2021) guidelines.", + "path": "input/data/feed/NASEM_Comp_with_TDN_urea.csv", + "type": "csv", + "properties": "NASEM_Comp_properties" + }, + "manure_management": { + "title": "Manure Processor Configurations", + "description": "Configurations of manure processors (handlers, separators, digesters, and storages) used in Manure module.", + "path": "input/data/manure/no_manure_processor_configs.json", + "type": "json", + "properties": "manure_management_properties" + }, + "manure_processor_connection": { + "title": "Manure Processor Connections", + "description": "The connection configs for all manure processors.", + "path": "input/data/manure/no_manure_processor_connections.json", + "type": "json", + "properties": "manure_processor_connection_properties" + }, + "crop_configurations": { + "title": "Crop Configurations", + "description": "Configurations for how crops grow and are managed.", + "path": "input/data/crop_configurations/no_crop_configs.json", + "type": "json", + "properties": "crop_configuration_properties" + }, + "no_field": { + "title": "Field specification", + "description": "Field characteristics and references to field management specifications.", + "path": "input/data/field/no_field.json", + "type": "json", + "properties": "field_properties" + }, + "no_soil": { + "title": "Soil data", + "description": "Characteristic soil information, including composition, slope, and layer details.", + "path": "input/data/soil/no_soil.json", + "type": "json", + "properties": "soil_profile_properties" + }, + "no_crop": { + "title": "Crop data", + "description": "Crop selection and detailed rotation schedules.", + "path": "input/data/crop/no_crop.json", + "type": "json", + "properties": "crop_schedule_properties" + }, + "no_fertilizer": { + "title": "Fertilizer schedule.", + "description": "Fertilizer types available, and their application schedule.", + "path": "input/data/fertilizer_schedule/no_fertilizer.json", + "type": "json", + "properties": "fertilizer_schedule_properties" + }, + "no_manure": { + "title": "Manure schedule.", + "description": "Specifies manure applications to a field.", + "path": "input/data/manure_schedule/no_manure.json", + "type": "json", + "properties": "manure_schedule_properties" + }, + "no_tillage": { + "title": "Tillage schedule.", + "description": "Schedule of tillage applications for a field.", + "path": "input/data/tillage_schedule/no_till.json", + "type": "json", + "properties": "tillage_schedule_properties" + }, + "weather": { + "title": "Weather data", + "description": "Weather data used during the simulation, including date, precipitation, temperature, etc.", + "path": "input/data/weather/example_temperate_weather.csv", + "type": "csv", + "properties": "weather_properties" + }, + "user_feeds": { + "title": "User Feed", + "description": "Summary of feeds available for use in the simulation, including their RuFaS ID, short descriptors, and which nutrient composition file they are included in.", + "path": "input/data/feed/user_feeds.csv", + "type": "csv", + "properties": "user_feeds_properties" + }, + "tractor_dataset": { + "title": "Tractor Dataset", + "description": "Details agricultural machinery operations for various crops, including tractor size, operations (planting, mowing, collection, etc.), implement details, operational parameters (depth, width, mass), and throughput metrics, spanning different crop types and soil management practices.", + "path": "input/data/EEE/no_tractor_dataset.csv", + "type": "csv", + "properties": "tractor_dataset_properties" + }, + "EEE_constants": { + "title": "EEE Constants", + "description": "The constants that are used in EEE module.", + "path": "input/data/EEE/constants.json", + "type": "json", + "properties": "EEE_constants_properties" + }, + "feed_management": { + "title": "Feed Management", + "description": "Configurations for feed storage units.", + "path": "input/data/feed_management/example_feed_storage_configs.json", + "type": "json", + "properties": "feed_storage_configurations" + }, + "feed_storage_configurations": { + "title": "Feed Management", + "description": "Configurations for feed storage units.", + "path": "input/data/feed_management/example_feed_storage_configs.json", + "type": "json", + "properties": "feed_storage_configurations" + }, + "feed_storage_instances": { + "title": "Feed Storages", + "description": "Names of feed storage configs used in the simulation.", + "path": "input/data/feed_management/example_freestall_feed_storages.json", + "type": "json", + "properties": "feed_storage_instances" + }, + "initial_feed_storage_contents": { + "title": "Initial feed storage contents", + "description": "The contents that feed storages hold when the simulation starts, in place of harvests from simulated fields.", + "path": "input/data/feed_management/example_feed_only_initial_contents.json", + "type": "json", + "properties": "initial_feed_storage_contents_properties" + } + }, + "runtime_metadata": { + "EEE_econ": { + "path": "input/metadata/EEE/econ_metadata.json" + } + } +} \ No newline at end of file diff --git a/tests/test_biophysical/test_feed_storage/test_feed_manager.py b/tests/test_biophysical/test_feed_storage/test_feed_manager.py index 121d028e33..2dc5c59b0d 100644 --- a/tests/test_biophysical/test_feed_storage/test_feed_manager.py +++ b/tests/test_biophysical/test_feed_storage/test_feed_manager.py @@ -1325,3 +1325,33 @@ def test_gather_available_feeds_by_id_groups_and_sorts() -> None: assert set(purchased_by_id.keys()) == {1, 2} assert purchased_by_id[1] == [p1, p1b] assert purchased_by_id[2] == [p2] + + +def test_stock_initial_storage_contents(mocker: MockerFixture, feed_manager: FeedManager, time: RufasTime) -> None: + """Tests that initial contents are stocked into the named storages, with a warning for unknown names.""" + storage = feed_manager.active_storages["example_pile"] + mocked_stock = mocker.patch.object(storage, "stock_initial_contents") + mock_add_warning = mocker.patch.object(feed_manager._om, "add_warning") + initial_contents = {"dry_matter_mass": 1000.0, "dry_matter_percentage": 35.0} + + feed_manager.stock_initial_storage_contents( + {"example_pile": initial_contents, "not_a_storage": initial_contents}, time + ) + + mocked_stock.assert_called_once_with(initial_contents, time.current_date.date(), time.simulation_day) + mock_add_warning.assert_called_once() + assert "not_a_storage" in mock_add_warning.call_args.args[1] + + +def test_stock_initial_storage_contents_without_input_is_a_no_op( + mocker: MockerFixture, feed_manager: FeedManager, time: RufasTime +) -> None: + """Tests that stocking with no initial contents input leaves every storage untouched.""" + mocked_stocks = [ + mocker.patch.object(storage, "stock_initial_contents") for storage in feed_manager.active_storages.values() + ] + + feed_manager.stock_initial_storage_contents({}, time) + + for mocked_stock in mocked_stocks: + mocked_stock.assert_not_called() diff --git a/tests/test_biophysical/test_feed_storage/test_storage.py b/tests/test_biophysical/test_feed_storage/test_storage.py index 6e93d30b7a..f0443fd580 100644 --- a/tests/test_biophysical/test_feed_storage/test_storage.py +++ b/tests/test_biophysical/test_feed_storage/test_storage.py @@ -650,3 +650,58 @@ def test_calculate_degradation_values(storage: Storage, mocker: MockerFixture) - mocker.call(mock_crop.ash, storage.ash_loss_coefficient, 50.0, 300.0), ] mock_recalc.assert_has_calls(expected_calls) + + +@pytest.fixture +def sample_initial_contents() -> dict[str, float]: + """Initial contents block as it appears in a storage configuration.""" + return { + "dry_matter_mass": 200000.0, + "dry_matter_percentage": 35.0, + "dry_matter_digestibility": 40.0, + "crude_protein_percent": 7.7, + "non_protein_nitrogen": 4.6, + "starch": 31.0, + "adf": 24.0, + "ndf": 40.9, + "lignin": 3.1, + "sugar": 1.5, + "ash": 3.8, + } + + +def test_stock_initial_contents(storage: Storage, sample_initial_contents: dict[str, float]) -> None: + """Tests that stocked initial contents become a stored crop with the stated mass and composition.""" + start_date = date(2013, 1, 1) + + storage.stock_initial_contents(sample_initial_contents, start_date, simulation_day=0) + + assert len(storage.stored) == 1 + crop = storage.stored[0] + assert crop.config_name == "corn_silage" + assert crop.field_name == "Test Field" + assert crop.harvest_time == start_date + assert crop.storage_time == start_date + assert crop.dry_matter_mass == sample_initial_contents["dry_matter_mass"] + assert crop.dry_matter_percentage == sample_initial_contents["dry_matter_percentage"] + assert crop.dry_matter_digestibility == sample_initial_contents["dry_matter_digestibility"] + assert crop.ash == sample_initial_contents["ash"] + + +def test_stock_initial_contents_applies_arrival_losses(sample_initial_contents: dict[str, float]) -> None: + """Tests that initial contents of a grain crop receive the same arrival loss as a harvested grain crop.""" + storage = Storage( + storage_config={ + "name": "Test Grain Storage", + "field_names": ["Test Field"], + "crop_name": "corn_grain", + "rufas_id": 1, + "initial_storage_dry_matter": 0.86, + "capacity": 1_000_000.0, + } + ) + + storage.stock_initial_contents(sample_initial_contents, date(2013, 1, 1), simulation_day=0) + + expected_dry_matter_mass = sample_initial_contents["dry_matter_mass"] * (1 - 0.01) + assert storage.stored[0].dry_matter_mass == pytest.approx(expected_dry_matter_mass) diff --git a/tests/test_simulation_engine.py b/tests/test_simulation_engine.py index 0cae8c1b5c..8817256c3c 100644 --- a/tests/test_simulation_engine.py +++ b/tests/test_simulation_engine.py @@ -42,6 +42,7 @@ def test_simulation_type_enum_values() -> None: assert SimulationType.FIELD_AND_FEED.value == "field_and_feed" assert SimulationType.FIELD_ONLY.value == "field_only" assert SimulationType.ANIMALS_ONLY.value == "animals_only" + assert SimulationType.FEED_ONLY.value == "feed_only" @pytest.mark.parametrize( @@ -51,6 +52,7 @@ def test_simulation_type_enum_values() -> None: (SimulationType.FIELD_AND_FEED, False), (SimulationType.FIELD_ONLY, False), (SimulationType.ANIMALS_ONLY, True), + (SimulationType.FEED_ONLY, False), ], ) def test_simulate_animals( @@ -85,6 +87,7 @@ def test_feed_simulation_types() -> None: assert SimulationType._feed_simulation_types() == { SimulationType.FULL_FARM, SimulationType.FIELD_AND_FEED, + SimulationType.FEED_ONLY, } @@ -424,6 +427,151 @@ def test_execute_field_and_feed_daily_simulation( mock_execute_daily_manure_operations.assert_not_called() +def test_execute_feed_only_daily_simulation( + simulation_engine: SimulationEngine, + mocker: MockerFixture, +) -> None: + """ + Unit test for function _execute_feed_only_daily_simulation in file + RUFAS/simulation_engine.py + """ + # Arrange + parent = MagicMock() + parent.attach_mock( + mocker.patch.object(simulation_engine, "_execute_feed_storage_upkeep"), + "execute_feed_storage_upkeep", + ) + parent.attach_mock( + mocker.patch.object(simulation_engine, "_report_daily_records"), + "report_daily_records", + ) + parent.attach_mock( + mocker.patch.object(simulation_engine, "_advance_time"), + "advance_time", + ) + + mock_execute_daily_field_operations = mocker.patch.object(simulation_engine, "_execute_daily_field_operations") + mock_receive_daily_harvested_crops = mocker.patch.object(simulation_engine, "_receive_daily_harvested_crops") + mock_execute_feed_planning = mocker.patch.object(simulation_engine, "_execute_feed_planning") + mock_execute_ration_planning = mocker.patch.object(simulation_engine, "_execute_ration_planning") + mock_execute_daily_animal_operations = mocker.patch.object(simulation_engine, "_execute_daily_animal_operations") + mock_execute_daily_manure_operations = mocker.patch.object(simulation_engine, "_execute_daily_manure_operations") + + # Act + simulation_engine._execute_feed_only_daily_simulation() + + # Assert + assert parent.mock_calls == [ + call.execute_feed_storage_upkeep(), + call.report_daily_records(), + call.advance_time(), + ] + + mock_execute_daily_field_operations.assert_not_called() + mock_receive_daily_harvested_crops.assert_not_called() + mock_execute_feed_planning.assert_not_called() + mock_execute_ration_planning.assert_not_called() + mock_execute_daily_animal_operations.assert_not_called() + mock_execute_daily_manure_operations.assert_not_called() + + +@pytest.mark.parametrize("is_time_to_process_degradations", [True, False]) +def test_execute_feed_storage_upkeep( + simulation_engine: SimulationEngine, + mocker: MockerFixture, + is_time_to_process_degradations: bool, +) -> None: + """ + Unit test for function _execute_feed_storage_upkeep in file RUFAS/simulation_engine.py + """ + # Arrange + simulation_engine.time = (mock_time := MagicMock(auto_spec=RufasTime)) + mock_time.simulation_day = 42 + simulation_engine.weather = MagicMock(auto_spec=Weather) + simulation_engine.feed_degradations_interval_length = timedelta(days=30) + mocker.patch.object( + SimulationEngine, + "_is_time_to_process_feed_degradations", + new_callable=mocker.PropertyMock, + return_value=is_time_to_process_degradations, + ) + + # Act + simulation_engine._execute_feed_storage_upkeep() + + # Assert + simulation_engine.feed_manager.report_feed_storage_levels.assert_called_once_with(42, "daily_storage_levels") + simulation_engine.feed_manager.report_cumulative_purchased_feeds.assert_called_once_with(42) + if is_time_to_process_degradations: + simulation_engine.feed_manager.process_degradations.assert_called_once_with( + simulation_engine.weather, mock_time + ) + assert ( + simulation_engine.next_degradations_processing + == (mock_time.current_date + simulation_engine.feed_degradations_interval_length).date() + ) + else: + simulation_engine.feed_manager.process_degradations.assert_not_called() + + +def test_gather_initial_feed_storage_contents(simulation_engine: SimulationEngine, mocker: MockerFixture) -> None: + """ + Unit test for function _gather_initial_feed_storage_contents in file RUFAS/simulation_engine.py + """ + # Arrange + first_contents = { + "initial_contents": [ + {"storage_name": "corn_silage_storage_1", "dry_matter_mass": 200000.0, "dry_matter_percentage": 35.0}, + {"storage_name": "corn_silage_storage_1", "dry_matter_mass": 1.0, "dry_matter_percentage": 1.0}, + ] + } + second_contents = { + "initial_contents": [ + {"storage_name": "alfalfa_hay_storage_2", "dry_matter_mass": 30000.0, "dry_matter_percentage": 88.0}, + ] + } + simulation_engine.im = MagicMock(auto_spec=InputManager) + simulation_engine.im.get_data_keys_by_properties.return_value = ["initial_contents_1", "initial_contents_2"] + simulation_engine.im.get_data.side_effect = [first_contents, second_contents] + mock_add_warning = mocker.patch.object(simulation_engine.om, "add_warning") + + # Act + initial_contents_by_storage_name = simulation_engine._gather_initial_feed_storage_contents() + + # Assert + assert initial_contents_by_storage_name == { + "corn_silage_storage_1": {"dry_matter_mass": 200000.0, "dry_matter_percentage": 35.0}, + "alfalfa_hay_storage_2": {"dry_matter_mass": 30000.0, "dry_matter_percentage": 88.0}, + } + mock_add_warning.assert_called_once() + assert "corn_silage_storage_1" in mock_add_warning.call_args.args[1] + + +@pytest.mark.parametrize("simulate_fields, expected_warning_count", [(False, 1), (True, 0)]) +def test_gather_initial_feed_storage_contents_no_input_files( + simulation_engine: SimulationEngine, + mocker: MockerFixture, + simulate_fields: bool, + expected_warning_count: int, +) -> None: + """ + Unit test for function _gather_initial_feed_storage_contents in file RUFAS/simulation_engine.py when no initial + contents input files are provided. + """ + # Arrange + simulation_engine.simulate_fields = simulate_fields + simulation_engine.im = MagicMock(auto_spec=InputManager) + simulation_engine.im.get_data_keys_by_properties.return_value = [] + mock_add_warning = mocker.patch.object(simulation_engine.om, "add_warning") + + # Act + initial_contents_by_storage_name = simulation_engine._gather_initial_feed_storage_contents() + + # Assert + assert initial_contents_by_storage_name == {} + assert mock_add_warning.call_count == expected_warning_count + + def test_execute_animals_only_daily_simulation( simulation_engine: SimulationEngine, mocker: MockerFixture, From 97575fc56305224464e316766e783da209b2bfa6 Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Fri, 31 Jul 2026 16:27:11 +0900 Subject: [PATCH 2/6] Add changelog entry for 3183 --- changelog_WIP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog_WIP.md b/changelog_WIP.md index a646c5eb4e..2f8cb82500 100644 --- a/changelog_WIP.md +++ b/changelog_WIP.md @@ -110,3 +110,4 @@ This **WIP Changelog** records development changes in progress and not yet inclu - [3150](https://github.com/RuminantFarmSystems/RuFaS/pull/3150) - [minor change] [Crop and Soil] [NoInputChange] [OutputChange] Re-implements `MineralizationDecomposition._calculate_nutrient_cycling_residue_composition_factor` to return the SWAT 3:1.2.8 value. - [3166](https://github.com/RuminantFarmSystems/RuFaS/pull/3166) - [minor change] [Dependency - Black] [NoInputChange] [NoOutputChange] Updates minimum Black version in `pyproject.toml` file dev section from 25.1.0 to 26.5.1. - [3099](https://github.com/RuminantFarmSystems/RuFaS/pull/3099) - [minor change] [Animal] [NoInputChange] [NoOutputChange] Adds a summary warning in `HerdManager.formulate_rations()` that reports the number of cows whose milk production was reduced due to ration formulation failure and the average reduction (kg), logged once per ration formulation interval via `OutputManager.add_warning()`. +- [3183](https://github.com/RuminantFarmSystems/RuFaS/pull/3183) - [minor change] [SimulationEngine] [Feed] [InputChange] [NoOutputChange] Adds a `feed_only` simulation type that runs the Feed Storage module without the crop and soil, animal, and manure modules, with feed storages given their starting contents by a new optional initial feed storage contents input. From 350c9b5b640aa054f9286fc28a4144fdad1f5b32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 31 Jul 2026 07:29:28 +0000 Subject: [PATCH 3/6] Apply Black Formatting From 77806d660f5e9cb3840cc954c72fddc16906b65d Mon Sep 17 00:00:00 2001 From: matthew7838 Date: Fri, 31 Jul 2026 07:33:22 +0000 Subject: [PATCH 4/6] Update badges on README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c378c18f0c..c450acc28b 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-1135%20errors-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-1139%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml) # RuFaS: Ruminant Farm Systems From 7dc3701f18a6c976936ef8fee7adffa7afdaac23 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 31 Jul 2026 14:36:22 +0000 Subject: [PATCH 5/6] Apply Black Formatting From 89c29cb15e191f44a191e96214cfebfdf4614d89 Mon Sep 17 00:00:00 2001 From: matthew7838 Date: Fri, 31 Jul 2026 14:41:28 +0000 Subject: [PATCH 6/6] Update badges on README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c450acc28b..a6047f9719 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![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-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) +[![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-1139%20errors-red)](https://github.com/RuminantFarmSystems/MASM/actions/workflows/combined_format_lint_test_mypy.yml)