From c263ce4dd77fb6988f1d6f6a1fbd009c84f6eb49 Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Fri, 21 Aug 2026 22:55:55 +0900 Subject: [PATCH 1/2] Port bedding cost preprocessing to a special-case handler Moves the issue #3088 bedding implementation from the dedicated_processor branch in EconomicPreprocessor onto the refactor_preprocess special-case architecture as BeddingRequirementsHandler. Behavior is unchanged: per-pen pairing of animal counts with each pen's own bedding price, name-to-type resolution through animal.bedding_configs, annual dollar-per-head billing by calendar year, LAC_COW-only billing per the SME-confirmed price basis, and the emitted econ_bedding_* reporting scalars. Verified identical output on the 7-year freestall example (623.02 head-years x $53.22 = $33,156.91). Also replaces the dangling SeedCostHandler import (seed_costs.py does not exist on the base branch) with the new handler registration. --- RUFAS/EEE/economics/mapping.py | 26 +- RUFAS/EEE/economics/preprocessing.py | 4 +- RUFAS/EEE/economics/special_cases/__init__.py | 4 +- .../special_cases/bedding_requirements.py | 744 ++++++++++++++++++ input/data/EEE/economics_map.json | 25 +- .../test_EEE/test_economics_preprocessing.py | 368 +++++++++ 6 files changed, 1159 insertions(+), 12 deletions(-) create mode 100644 RUFAS/EEE/economics/special_cases/bedding_requirements.py diff --git a/RUFAS/EEE/economics/mapping.py b/RUFAS/EEE/economics/mapping.py index 91dd2d0eb5..1d83e2c385 100644 --- a/RUFAS/EEE/economics/mapping.py +++ b/RUFAS/EEE/economics/mapping.py @@ -49,8 +49,28 @@ "Bedding requirements": { "biophysical_simulation": ["AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_.*"], "input_manager": ["animal.pen_information.*.manure_streams.0.bedding_name"], - "match_source": "input_manager", - "wildcard_value_map": {"0_CALF": "0", "1_GROWING": "1", "2_CLOSE_UP": "2", "3_LAC_COW": "3"}, + # Handled by special_cases.bedding_requirements.BeddingRequirementsHandler + # (issue #3088): each pen's bedding price is paired with that pen's + # animal count and converted from the annual dollar-per-head price. + "bedding_configs_path": "animal.bedding_configs", + # SME-confirmed: the dollar-per-head bedding prices are per LACTATING + # cow, so only lactating-cow pens are billed. Other pens still get + # physical bedding in the biophysical simulation; they are just not + # part of this economic line item. + "billable_pen_combinations": ["LAC_COW"], + # A pen's manure_stream bedding_name is a user config name; resolve + # it to the config's canonical bedding_type, then to an economics + # file key. This table is the COMPLETE registry of billable bedding + # types: identity entries (e.g. "sand" -> "sand") are deliberate so + # the supported set is explicit in one place. A type absent here + # (e.g. "none") incurs no cost. + "bedding_type_to_file_key": { + "sand": "sand", + "sawdust": "sawdust", + "straw": "straw", + "CBPB sawdust": "CBPB", + "manure solids": "manure_solids", + }, "economics_files": { "CBPB": "commodity_prices_bedding_compost_bedded_pack_dollar_per_head", "manure_solids": "commodity_prices_bedding_manure_solids_dollar_per_head", @@ -58,7 +78,7 @@ "sawdust": "commodity_prices_bedding_sawdust_dollar_per_head", "straw": "commodity_prices_bedding_straw_dollar_per_head", }, - "preprocessing": "average number of animals in each pen", + "preprocessing": "per-pen annual bedding cost: average head per year x dollar-per-head-per-year", }, "Purchased heifers": { "biophysical_simulation": ["AnimalModuleReporter.report_life_cycle_manager_data.bought_heifer_num"], diff --git a/RUFAS/EEE/economics/preprocessing.py b/RUFAS/EEE/economics/preprocessing.py index 390bce3f3e..7a4968bca8 100644 --- a/RUFAS/EEE/economics/preprocessing.py +++ b/RUFAS/EEE/economics/preprocessing.py @@ -32,7 +32,7 @@ from RUFAS.util import Aggregator from RUFAS.EEE.economics.mapping import ECONOMIC_MAP from RUFAS.EEE.economics.preprocessing_context import PreprocessingContext -from RUFAS.EEE.economics.special_cases import SpecialCaseHandler, SeedCostHandler +from RUFAS.EEE.economics.special_cases import SpecialCaseHandler, BeddingRequirementsHandler from RUFAS.EEE.economics.fallback_values import ( BIOPHYSICAL_FALLBACKS, ECONOMIC_PRICE_FALLBACK, @@ -44,7 +44,7 @@ COMPUTED_PREPROCESSING_INPUT_PATH = Path("") SPECIAL_CASE_HANDLERS: list[type[SpecialCaseHandler]] = [ - SeedCostHandler, + BeddingRequirementsHandler, ] diff --git a/RUFAS/EEE/economics/special_cases/__init__.py b/RUFAS/EEE/economics/special_cases/__init__.py index 7f0468c563..c7d63f3a03 100644 --- a/RUFAS/EEE/economics/special_cases/__init__.py +++ b/RUFAS/EEE/economics/special_cases/__init__.py @@ -12,7 +12,7 @@ """ from RUFAS.EEE.economics.special_cases.base import SpecialCaseHandler -from RUFAS.EEE.economics.special_cases.seed_costs import SeedCostHandler +from RUFAS.EEE.economics.special_cases.bedding_requirements import BeddingRequirementsHandler -__all__ = ["SpecialCaseHandler", "SeedCostHandler",] +__all__ = ["SpecialCaseHandler", "BeddingRequirementsHandler"] diff --git a/RUFAS/EEE/economics/special_cases/bedding_requirements.py b/RUFAS/EEE/economics/special_cases/bedding_requirements.py new file mode 100644 index 0000000000..83b5190925 --- /dev/null +++ b/RUFAS/EEE/economics/special_cases/bedding_requirements.py @@ -0,0 +1,744 @@ +"""Special-case preprocessing for the ``Bedding requirements`` line item. + +Bedding prices are annual ``dollar_per_head`` commodity tables, while the +simulation reports each pen's animal count daily. The generic pipeline cannot +express this line item because each pen's count must be paired with that pen's +own bedding price: the pen's ``bedding_name`` (a user config name such as +``"calf_straw"``) is resolved to its canonical ``bedding_type`` (``"straw"``) +and then to a price file, and each calendar year is billed as ``(average head +present that year) * (that year's dollar-per-head price)``, summed across pens +and years (issue #3088). Only pens whose animal combination is listed in the +mapping's ``billable_pen_combinations`` are billed, because the prices are per +lactating cow. +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, ClassVar + +from RUFAS.general_constants import GeneralConstants +from RUFAS.units import MeasurementUnits +from RUFAS.util import Utility +from RUFAS.EEE.economics import mapping +from RUFAS.EEE.economics.fallback_values import ECONOMIC_PRICE_FALLBACK +from RUFAS.EEE.economics.special_cases.base import SpecialCaseHandler + + +class BeddingRequirementsHandler(SpecialCaseHandler): + """Owner of the preprocessing for the ``Bedding requirements`` line item.""" + + section: ClassVar[str] = "Animal" + name: ClassVar[str] = "Bedding requirements" + + def _get_mapping_details(self) -> dict[str, Any]: + """ + Returns this line item's entry from the economic mapping. + + Returns + ------- + dict[str, Any] + The ``Bedding requirements`` mapping entry, or an empty dict when the + entry is absent. + + Notes + ----- + The mapping module is read dynamically (rather than importing the + ``ECONOMIC_MAP`` name directly) so the entry stays patchable in tests. + """ + for entries in mapping.ECONOMIC_MAP.get(self.section, {}).values(): + if isinstance(entries, dict) and self.name in entries: + details = entries[self.name] + return details if isinstance(details, dict) else {} + return {} + + def _normalize_bedding_type(self, raw: Any) -> str: + """ + Normalizes a bedding type into a plain string. + + Parameters + ---------- + raw : Any + The bedding type value to normalize. + + Returns + ------- + str + ``raw.value`` when ``raw`` exposes a ``value`` attribute, otherwise ``raw``, + converted to a string. + + Notes + ----- + A bedding type may be a plain string (read directly from JSON input) or an + enum-like object exposing a ``value`` attribute. Returning the underlying + value as a string in both cases lets downstream lookups always compare + against strings. + + """ + return str(getattr(raw, "value", raw)) + + def _build_bedding_name_to_type(self, details: dict[str, Any]) -> dict[str, str]: + """ + Maps each bedding config ``name`` to its canonical ``bedding_type``. + + Parameters + ---------- + details : dict[str, Any] + The mapping entry being processed. Only ``bedding_configs_path`` is + used here. + + Returns + ------- + dict[str, str] + A mapping of each bedding config ``name`` to its ``bedding_type``. + Empty when no bedding configs are available. + + Notes + ----- + A pen refers to its bedding by the user-defined ``name`` field of a + bedding config (e.g. ``"calf_straw"``), while commodity price files are + keyed by the canonical ``bedding_type`` (e.g. ``"straw"``). This lookup + translates the former into the latter so the correct price file can be + selected for each pen (issue #3088). + + The bedding configs are read from the InputManager at + ``bedding_configs_path`` (defaulting to ``"animal.bedding_configs"``) + and are expected to be a list of dicts. A missing or non-list config blob, + or an entry without a ``name`` and ``bedding_type``, emits a warning; the + malformed entry is skipped. + + Examples + -------- + Given the following ``animal.bedding_configs`` input:: + + [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "closeup_sawdust", "bedding_type": "sawdust"}, + {"name": "lac_and_growing_sand", "bedding_type": "sand"}, + {"name": "none (no bedding)", "bedding_type": "none"}, + ] + + the returned mapping is:: + + { + "calf_straw": "straw", + "closeup_sawdust": "sawdust", + "lac_and_growing_sand": "sand", + "none (no bedding)": "none", + } + + A pen whose ``bedding_name`` is ``"calf_straw"`` therefore resolves to + type ``"straw"``, which selects the straw ``dollar_per_head`` price file. + A pen resolving to ``"none"`` is later treated as having no bedding cost. + """ + + info_map = {"class": self.__class__.__name__, "function": self._build_bedding_name_to_type.__name__} + path = details.get("bedding_configs_path") or "animal.bedding_configs" + configs = self.context.im.get_data(path) + name_to_type: dict[str, str] = {} + if not isinstance(configs, (list, tuple)): + self.context.om.add_warning( + "InvalidBeddingConfigs", + f"Bedding configs at '{path}' are missing or not a list; no bedding names can be resolved", + info_map, + ) + return name_to_type + for config in configs: + if not isinstance(config, dict) or "name" not in config or "bedding_type" not in config: + self.context.om.add_warning( + "InvalidBeddingConfigEntry", + f"Skipping malformed bedding config entry (missing 'name' or 'bedding_type'): {config!r}", + info_map, + ) + continue + name_to_type[str(config["name"])] = self._normalize_bedding_type(config["bedding_type"]) + return name_to_type + + def _get_simulation_start_date(self) -> datetime: + """ + Returns the simulation start date. + + Returns + ------- + datetime + The calendar date of the first simulation day. + + Notes + ----- + The start date is read from ``config.start_date`` in the ``YYYY:day_of_year`` + format (for example ``"2013:20"``) and converted to a calendar date. The + start year, when needed, is available as ``.year`` on the returned date. + + """ + return datetime.strptime(str(self.context.im.get_data("config.start_date")), "%Y:%j") + + def _extract_output_data(self, output_data: Any) -> tuple[list[float], list[Any]]: + """ + Extracts aligned daily values and info maps from one OutputManager entry. + + Parameters + ---------- + output_data : Any + The stored output data for a single reported variable. + + Returns + ------- + tuple[list[float], list[Any]] + The numeric daily values. + The info map for each value, index-aligned with the values. + + Notes + ----- + The output data is normally a mapping of the form + ``{"values": [...], "info_maps": [...]}``, but a bare list or scalar is also + accepted. Non-numeric values are dropped with a warning, and each retained + value keeps its matching info map (an empty dict when none exists). + + """ + + if isinstance(output_data, dict) and "values" in output_data: + raw_values = output_data.get("values", []) + raw_info_maps = output_data.get("info_maps", []) + elif isinstance(output_data, (list, tuple)): + raw_values, raw_info_maps = list(output_data), [] + else: + raw_values, raw_info_maps = [output_data], [] + + values: list[float] = [] + info_maps: list[Any] = [] + dropped_count = 0 + for index, value in enumerate(raw_values): + try: + numeric = float(value) + except (TypeError, ValueError): + dropped_count += 1 + continue + values.append(numeric) + info_maps.append(raw_info_maps[index] if index < len(raw_info_maps) else {}) + if dropped_count: + self.context.om.add_warning( + "NonNumericOutputData", + f"Dropped {dropped_count} non-numeric value(s) from an output data entry", + {"class": self.__class__.__name__, "function": self._extract_output_data.__name__}, + ) + return values, info_maps + + def _collect_pen_data(self, sim_paths: list[str]) -> dict[str, dict[str, dict[str, list[Any]]]]: + """ + Collects each pen's daily head counts from the OutputManager, grouped by scenario. + + Parameters + ---------- + sim_paths : list[str] + The biophysical variable-name patterns to match. + + Returns + ------- + dict[str, dict[str, dict[str, list[Any]]]] + A nested mapping of the form + ``{scenario: {pen_capture: {"values": [...], "info_maps": [...]}}}``. + + Notes + ----- + Each pattern is matched against the variables pool; the wildcard portion of a + matched variable name (for example ``"0_CALF"``) identifies the pen. Results are + grouped by scenario, which is ``"baseline"`` for a single run or the run's name + in a multi-run comparison. + + Examples + -------- + For a single run whose pool holds two pen variables, e.g. + ``number_of_animals_in_pen_0_CALF`` and ``number_of_animals_in_pen_3_LAC_COW``, + the returned structure looks like:: + + { + "baseline": { + "0_CALF": { + "values": [9, 9, 8], + "info_maps": [ + {"units": "animals", "simulation_day": 0}, + {"units": "animals", "simulation_day": 1}, + {"units": "animals", "simulation_day": 2}, + ], + }, + "3_LAC_COW": { + "values": [88, 89, 88], + "info_maps": [ + {"units": "animals", "simulation_day": 0}, + {"units": "animals", "simulation_day": 1}, + {"units": "animals", "simulation_day": 2}, + ], + }, + } + } + + """ + + pool = getattr(self.context.om, "variables_pool", {}) + baseline_mode = True + scenario_names: set[str] = set() + if isinstance(pool, dict) and pool: + baseline_mode = all(isinstance(value, dict) and "values" in value for value in pool.values()) + if not baseline_mode: + scenario_names = {name for name, data in pool.items() if isinstance(data, dict) and data} + + pen_data: dict[str, dict[str, dict[str, list[Any]]]] = {} + for path in sim_paths: + capture_pattern = re.compile(path.replace(".*", "(.+?)") + "$") + filtered_pool = self.context.om.filter_variables_pool({"filters": [path]}) + for variable_name, output_data in filtered_pool.items(): + match = capture_pattern.search(variable_name) + if match is None: + continue + groups = [group for group in match.groups() if group] + if not groups: + continue + capture = groups[0] + if baseline_mode: + scenario = "baseline" + else: + # Mirror the generic engine: only accept a discovered scenario + # name, otherwise fall back to "baseline". + candidate = variable_name.split(".", 1)[0] + scenario = candidate if candidate in scenario_names else "baseline" + values, info_maps = self._extract_output_data(output_data) + bucket = pen_data.setdefault(scenario, {}).setdefault(capture, {"values": [], "info_maps": []}) + bucket["values"].extend(values) + bucket["info_maps"].extend(info_maps) + return pen_data + + def _group_daily_counts_by_year( + self, values: list[float], info_maps: list[Any], start_date: datetime + ) -> dict[int, list[float]]: + """ + Groups a pen's daily head counts by calendar year. + + Parameters + ---------- + values : list[float] + The pen's daily head counts. + info_maps : list[Any] + The info map for each value, providing ``simulation_day``. Index-aligned + with ``values``. + start_date : datetime + The calendar date of the first simulation day. + + Returns + ------- + dict[int, list[float]] + A mapping of each calendar year to the daily head counts recorded in that year. + + Notes + ----- + Each value's calendar date is ``start_date`` plus its ``simulation_day`` (taken + from the info map, or the 0-indexed position when absent). ``simulation_day`` is + itself 0-indexed (``(current_date - start_date).days``), so no offset is applied. + + """ + + counts_by_year: dict[int, list[float]] = defaultdict(list) + for index, value in enumerate(values): + sim_day = None + if index < len(info_maps) and isinstance(info_maps[index], dict): + sim_day = info_maps[index].get("simulation_day") + if sim_day is None: + sim_day = index + calendar_date = start_date + timedelta(days=int(sim_day)) + counts_by_year[calendar_date.year].append(value) + return counts_by_year + + def _get_annual_bedding_price( + self, + price_dict: dict[str, list[float | str]], + year: int, + fips: Any, + file_key: str, + warned_years: set[str], + info_map: dict[str, str], + ) -> float: + """ + Looks up the dollar-per-head bedding price for a year at the simulation county. + + Parameters + ---------- + price_dict : dict[str, list[float | str]] + The loaded price table, expected as ``{"fips": [...], "": [...], ...}``. + year : int + The calendar year to price. + fips : Any + The simulation's FIPS county code. + file_key : str + The price-file key, used in warning messages and to de-duplicate warnings. + warned_years : set[str] + The set of warning keys already emitted, so each warning fires only once. + info_map : dict[str, str] + Contextual information attached to any emitted warning. + + Returns + ------- + float + The dollar-per-head price for the year, or the fallback cost when unavailable. + + Notes + ----- + The price table holds one column per year, indexed by FIPS county code. When + the requested year has no column, the nearest available year is used. A missing + or malformed table, an unknown county, or a missing year each emit a single + warning and fall back to the default cost. + + """ + + fallback = ECONOMIC_PRICE_FALLBACK.get("cost", 1.0) + if not isinstance(price_dict, dict) or not isinstance(price_dict.get("fips"), list): + warn_key = f"{file_key}:malformed" + if warn_key not in warned_years: + warned_years.add(warn_key) + self.context.om.add_warning( + "MissingPriceData", + f"Bedding price data for '{file_key}' is missing or malformed; " + f"using fallback cost ${fallback}/head.", + info_map, + ) + return fallback + try: + fips_idx = price_dict["fips"].index(fips) + except ValueError: + warn_key = f"{file_key}:fips:{fips}" + if warn_key not in warned_years: + warned_years.add(warn_key) + self.context.om.add_warning( + "MissingPriceData", + f"FIPS county '{fips}' not found in bedding price file '{file_key}'; " + f"using fallback cost ${fallback}/head.", + info_map, + ) + return fallback + + year_columns = sorted(int(key) for key in price_dict if str(key).isdigit()) + if not year_columns: + return fallback + + use_year = year if year in year_columns else min(year_columns, key=lambda candidate: abs(candidate - year)) + if use_year != year: + warn_key = f"{file_key}:{year}" + if warn_key not in warned_years: + warned_years.add(warn_key) + self.context.om.add_warning( + "MissingPriceYear", + f"No bedding price column for year {year} in '{file_key}'; using nearest year {use_year}.", + info_map, + ) + try: + return float(price_dict[str(use_year)][fips_idx]) + except (KeyError, IndexError, TypeError, ValueError): + return fallback + + def _get_pen_bedding_name(self, pen_entry: Any, bedding_name_keys: list[str]) -> Any: + """ + Gets a pen entry's bedding name by following the mapping path keys. + + Parameters + ---------- + pen_entry : Any + One entry from the ``pen_information`` list. + bedding_name_keys : list[str] + The ordered keys or indices leading to the bedding name inside the + entry, for example ``["manure_streams", "0", "bedding_name"]``. + + Returns + ------- + Any + The bedding name, or ``None`` if the path cannot be resolved. + + Notes + ----- + Numeric keys index into lists and string keys index into dicts. Any missing key, + out-of-range index, or non-indexable value stops the walk and returns ``None``. + + """ + + # Step one key at a time, descending deeper into the entry each pass. + data = pen_entry + for key in bedding_name_keys: + if isinstance(data, (list, tuple)): + # A list step: the key must be a usable integer index. + try: + index = int(key) # e.g. "0" -> 0 + except (TypeError, ValueError): + return None # key wasn't a number, so it can't index a list + if not 0 <= index < len(data): + return None # index off the end of the list + data = data[index] + elif isinstance(data, dict): + # A dict step: the key must be present. + if key not in data: + return None + data = data[key] + else: + # Ran out of nesting before running out of keys (e.g. hit a str/int). + return None + return data # the bedding name at the end of the path + + def _build_pen_id_to_bedding_name(self, details: dict[str, Any]) -> dict[str, Any]: + """ + Maps each pen's ``id`` to its first manure stream's ``bedding_name``. + + Parameters + ---------- + details : dict[str, Any] + The mapping entry being processed. Its ``input_manager`` path locates the pens. + + Returns + ------- + dict[str, Any] + A mapping of each pen ``id`` (as a string) to its bedding name. Empty when no + pen list is available. + + Notes + ----- + Pen totals are reported under the pen's ``id`` field, while the input + ``pen_information`` is a list whose order need not match those ids. Matching on + ``id`` (rather than list position) keeps each pen paired with its own bedding + even when ids are reordered or non-contiguous (issue #3088). The pen list and + the bedding-name location within each entry are derived from the mapping's + ``input_manager`` path. + + """ + + info_map = {"class": self.__class__.__name__, "function": self._build_pen_id_to_bedding_name.__name__} + input_paths = details.get("input_manager") or [] + # The mapping path, e.g. "animal.pen_information.*.manure_streams.0.bedding_name". + # The "*" stands for "each pen"; everything before it locates the pen list, + # everything after it locates the bedding name inside one pen entry. + template = input_paths[0] if input_paths else "animal.pen_information.*.manure_streams.0.bedding_name" + if "*" not in template: + return {} # nothing per-pen to resolve + + # Split on ".*." -> prefix="animal.pen_information", suffix="manure_streams.0.bedding_name". + prefix, _, suffix = template.partition(".*.") + pens = self.context.im.get_data(prefix) # the whole pen_information list + + pen_map: dict[str, Any] = {} + if not isinstance(pens, (list, tuple)): + self.context.om.add_warning( + "InvalidPenInformation", + f"Pen information at '{prefix}' is missing or not a list; no bedding can be billed", + info_map, + ) + return pen_map + bedding_name_keys = suffix.split(".") if suffix else [] # ["manure_streams", "0", "bedding_name"] + for entry in pens: + # Key the map by the pen's own "id" field (NOT its list position), so a + # reordered or non-contiguous pen list still pairs correctly (issue #3088). + if isinstance(entry, dict) and "id" in entry: + pen_map[str(entry["id"])] = self._get_pen_bedding_name(entry, bedding_name_keys) + return pen_map + + def process(self) -> dict[str, Any]: + """ + Computes bedding cost per pen, per year, then sums them (issue #3088). + + Returns + ------- + dict[str, Any] + The preprocessed line item, matching the shape produced by the generic + pipeline (including ``line_item_values_by_scenario`` and ``flow_type``). + + Notes + ----- + For each pen the bedding name (an input config name) is resolved to its + canonical ``bedding_type`` and then to a price file. For each simulation year + the cost is ``(average head present that year) * (that year's dollar-per-head + price)``. Pens with no bedding (``bedding_type`` of ``"none"``) incur no cost. + + When the mapping declares ``billable_pen_combinations`` (e.g. ``["LAC_COW"]``, + because the dollar-per-head prices are per lactating cow), pens of any other + animal combination are excluded from the bill. + + The headline results are also emitted as small standalone output variables + (``econ_bedding_total_cost``, ``econ_bedding_billed_head_years``, + ``econ_bedding_avg_price_per_head_year``) for lightweight report filters. + + """ + + info_map = {"class": self.__class__.__name__, "function": self.process.__name__} + details = self._get_mapping_details() + + # Lookup tables built once up front: + name_to_type = self._build_bedding_name_to_type(details) # bedding name -> type ("calf_straw" -> "straw") + pen_id_to_bedding_name = self._build_pen_id_to_bedding_name(details) # pen id -> its bedding name + # type -> price-file key ("CBPB sawdust" -> "CBPB"); the complete registry + # of billable bedding types, so lookups are strict (no fallback). + type_to_key = details.get("bedding_type_to_file_key") or {} + normalized_type_to_key = {str(key).strip().lower(): value for key, value in type_to_key.items()} + economics_files_details = details.get("economics_files") + economics_files = economics_files_details if isinstance(economics_files_details, dict) else {} + # Which pen types the price basis covers (e.g. only LAC_COW: the dollar-per-head + # bedding prices are per lactating cow). None means every pen is billable. + billable_pen_combinations = details.get("billable_pen_combinations") + billable_combinations = ( + {str(combination).strip().upper() for combination in billable_pen_combinations} + if billable_pen_combinations + else None + ) + + start_date = self._get_simulation_start_date() # simulation start date, to bucket days into years + fips = self.context.im.get_data("config.FIPS_county_code") # county, to pick the right price row + + # Each pen's daily head counts, grouped by scenario (= one simulation run; usually just "baseline"). + sim_paths = [str(path) for path in details.get("biophysical_simulation") or []] + pen_data_by_scenario = self._collect_pen_data(sim_paths) + if not pen_data_by_scenario: + pen_data_by_scenario = {"baseline": {}} + + price_cache: dict[str, Any] = {} # avoid re-loading the same price file twice + price_data: dict[str, Any] = {} + warned_years: set[str] = set() # so a missing-price warning fires once, not per day + price_values: list[float] = [] + + line_item_values_by_scenario: dict[str, float] = {} + values_by_scenario: dict[str, list[float]] = {} + aggregates_by_scenario: dict[str, float] = {} + + for scenario, pens in pen_data_by_scenario.items(): # each simulation run + scenario_cost = 0.0 + scenario_head_years = 0.0 # the quantity: average head summed over pens and years + scenario_values: list[float] = [] + for capture, daily_data in pens.items(): # each pen in that run + pen_id = capture.split("_", 1)[0] # "0_CALF" -> "0" + pen_combination = capture.split("_", 1)[1] if "_" in capture else "" # "0_CALF" -> "CALF" + + # Step 0: is this pen type covered by the price basis? The SME-confirmed + # dollar-per-head prices are per LACTATING COW, so other pens (calves, + # growing heifers, close-up) are excluded from the bedding bill. + if billable_combinations is not None and pen_combination.strip().upper() not in billable_combinations: + scenario_values.extend(daily_data.get("values", [])) # keep for the audit trail, no cost + continue + + # Step 1: which bedding does this pen use? + bedding_name = pen_id_to_bedding_name.get(pen_id) + if bedding_name is None: + self.context.om.add_warning("MissingBeddingName", f"No bedding_name for pen '{pen_id}'", info_map) + continue + + # Step 2: bedding name -> its type. + bedding_type = name_to_type.get(str(bedding_name)) + if bedding_type is None: + self.context.om.add_warning( + "UnknownBeddingConfig", + f"Bedding '{bedding_name}' (pen '{pen_id}') is not in bedding_configs; no cost applied", + info_map, + ) + continue + + # No bedding -> pen is free, skip it. + normalized_type = str(bedding_type).strip().lower() + if not normalized_type or normalized_type == "none": + scenario_values.extend(daily_data.get("values", [])) + continue + + # Step 3: type -> price-file key -> price file path. + file_key = normalized_type_to_key.get(normalized_type) + economics_file = economics_files.get(file_key) if file_key else None + if file_key is None or economics_file is None: + self.context.om.add_warning( + "UnmappedBeddingType", + f"Bedding type '{bedding_type}' (pen '{pen_id}') has no economics price file; " + "no cost applied", + info_map, + ) + continue + + # Step 4: load that price file (cached), skip the pen if it can't be read. + if file_key not in price_cache: + fetched = self.context.get_data_with_handling(economics_file, info_map) + price_cache[file_key] = fetched + if fetched is not None: + price_data[file_key] = fetched + pen_price_dict = price_cache[file_key] + if pen_price_dict is None: + self.context.om.add_warning( + "MissingBeddingPriceFile", + f"Bedding type '{bedding_type}' (pen '{pen_id}') maps to '{economics_file}' " + "which could not be loaded; no cost applied", + info_map, + ) + scenario_values.extend(daily_data.get("values", [])) + continue + + # Step 5: cost this pen, one calendar year at a time. + daily_values = daily_data.get("values", []) + daily_info_maps = daily_data.get("info_maps", []) + scenario_values.extend(daily_values) + + counts_by_year = self._group_daily_counts_by_year(daily_values, daily_info_maps, start_date) + for year, daily in counts_by_year.items(): + if not daily: + continue + # average head that year = head-days / days in that year (366 in leap years) + days_in_year = ( + GeneralConstants.LEAP_YEAR_LENGTH + if Utility.is_leap_year(year) + else GeneralConstants.YEAR_LENGTH + ) + average_head = sum(daily) / days_in_year + price = self._get_annual_bedding_price(pen_price_dict, year, fips, file_key, warned_years, info_map) + price_values.append(price) + scenario_head_years += average_head # quantity: head-years billed + scenario_cost += average_head * price # add this pen-year to the running total + + # This run's totals. ``biophysical_aggregate`` is the QUANTITY (head-years) + # and ``line_item_values`` is the COST, so the reported quantity x price + # reconciles with the total the way every other line item does. + line_item_values_by_scenario[scenario] = scenario_cost + values_by_scenario[scenario] = scenario_values + aggregates_by_scenario[scenario] = scenario_head_years + + if details.get("economics_files") and not price_data: + self.context.om.add_warning( + "MissingEconomicsFile", + f"No commodity pricing retrieved for '{self.name}'", + info_map, + ) + + total_cost = sum(line_item_values_by_scenario.values()) + total_head_years = sum(aggregates_by_scenario.values()) + price_aggregate = self.context.aggregate(price_values, "average") + + # Small standalone reporting variables: the full line-item dict is too + # large for the report generator, so the headline numbers are emitted as + # scalars that a lightweight report filter can pick up directly. + self.context.om.add_variable( + "econ_bedding_total_cost", + total_cost, + dict(info_map, units=MeasurementUnits.DOLLARS), + ) + self.context.om.add_variable( + "econ_bedding_billed_head_years", + total_head_years, + dict(info_map, units=MeasurementUnits.ANIMALS), + ) + self.context.om.add_variable( + "econ_bedding_avg_price_per_head_year", + price_aggregate if price_aggregate is not None else 0.0, + dict(info_map, units=MeasurementUnits.DOLLARS), + ) + + # Package the result in the same shape the generic pipeline returns. + biophysical_values = [value for scenario_values in values_by_scenario.values() for value in scenario_values] + return { + "biophysical_values": biophysical_values, + "biophysical_aggregate": total_head_years, # total head-years (quantity, not cost) + "biophysical_values_by_scenario": values_by_scenario, + "biophysical_aggregate_by_scenario": aggregates_by_scenario, + "price_data": price_data, + "price_values": price_values, + "price_aggregate": price_aggregate, + "line_item_values_by_scenario": line_item_values_by_scenario, + "flow_type": "cost", + } + + +__all__ = ["BeddingRequirementsHandler"] diff --git a/input/data/EEE/economics_map.json b/input/data/EEE/economics_map.json index f720410a1e..315048517f 100644 --- a/input/data/EEE/economics_map.json +++ b/input/data/EEE/economics_map.json @@ -24,16 +24,31 @@ "processing": "" }, "Bedding requirements": { - "biophysical_simulation": ["AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_.*"], + "biophysical_simulation": [ + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_.*" + ], + "input_manager": [ + "animal.pen_information.*.manure_streams.0.bedding_name" + ], + "bedding_configs_path": "animal.bedding_configs", + "billable_pen_combinations": [ + "LAC_COW" + ], + "bedding_type_to_file_key": { + "sand": "sand", + "sawdust": "sawdust", + "straw": "straw", + "CBPB sawdust": "CBPB", + "manure solids": "manure_solids" + }, "economics_files": { - "input_manager_location": "animal.pen_information..*.manure_streams.0.bedding_name", "sand": "commodity_prices.bedding_sand.dollar_per_head.csv", - "staw": "commodity_prices.bedding_straw.dollar_per_head.csv", + "straw": "commodity_prices.bedding_straw.dollar_per_head.csv", "sawdust": "commodity_prices.bedding_sawdust.dollar_per_head.csv", "manure_solids": "commodity_prices.bedding_manure_solids.dollar_per_head.csv", "CBPB": "commodity_prices.bedding_compost_bedded_pack.dollar_per_head.csv" }, - "preprocessing": "dollar per head is reported on an annual basis, Map bedding price to number of animals for each bedding type, depending on their pen." + "preprocessing": "Per pen: resolve the pen's bedding_name to a bedding_type, select the matching dollar-per-head price file, then bill (average head present per year) * (that year's price), summed across pens and years. Only pens in billable_pen_combinations are billed (prices are per lactating cow); pens with bedding_type 'none' incur no cost." }, "Animal - Labor hours": { "input_manager": [ @@ -588,4 +603,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/test_EEE/test_economics_preprocessing.py b/tests/test_EEE/test_economics_preprocessing.py index 9228fe8899..a03d226bf7 100644 --- a/tests/test_EEE/test_economics_preprocessing.py +++ b/tests/test_EEE/test_economics_preprocessing.py @@ -1,6 +1,7 @@ import pytest import re +from RUFAS.EEE.economics import mapping as economics_mapping from RUFAS.EEE.economics import preprocessing @@ -9,6 +10,7 @@ def __init__(self, pool): self._pool = pool self.warnings = [] self.logs = [] + self.added_variables = [] def _get_flat_variables_pool(self): return self._pool @@ -26,6 +28,9 @@ def add_warning(self, code, message, info): def add_log(self, title, message, info): self.logs.append((title, message, info)) + def add_variable(self, variable_name, value, info_map=None, **kwargs): + self.added_variables.append((variable_name, value)) + class DummyInputManager: def __init__(self, data): @@ -531,3 +536,366 @@ def test_preprocess_expands_input_wildcard_with_value_map(monkeypatch: pytest.Mo {"X": "A", "Y": "B", "Z": "C"}, ) assert values == [5.0, 6.0, 7.0] + + +def _daily(head_per_day: int, days: int, start_day: int = 0) -> dict: + """Build a pen daily-head payload with one ``simulation_day`` per value.""" + + return { + "values": [head_per_day] * days, + "info_maps": [{"simulation_day": day} for day in range(start_day, start_day + days)], + } + + +def _pen(pen_id: int, bedding_name: str) -> dict: + """Build a ``pen_information`` entry referencing a bedding config by name.""" + + return {"id": pen_id, "manure_streams": [{"bedding_name": bedding_name}]} + + +def _run_bedding( + monkeypatch: pytest.MonkeyPatch, + im_data: dict, + pool: dict, + *, + pens: list, + type_to_key: dict, + economics_files: dict, + configs_path: str = "animal.bedding_configs", + billable_pen_combinations: list | None = None, +): + """Run the bedding special-case handler and return its result item + OM. + + ``pens`` is injected as the real ``animal.pen_information`` list so the + processor resolves bedding by each pen's ``id`` field (not list position). + """ + + dummy_im = DummyInputManager({**im_data, "animal.pen_information": pens}) + dummy_om = DummyOutputManager(pool) + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + bedding_entry = { + "biophysical_simulation": ["AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_.*"], + "input_manager": ["animal.pen_information.*.manure_streams.0.bedding_name"], + "bedding_configs_path": configs_path, + "bedding_type_to_file_key": type_to_key, + "economics_files": economics_files, + } + if billable_pen_combinations is not None: + bedding_entry["billable_pen_combinations"] = billable_pen_combinations + economic_map = {"Animal": {"Costs": {"Bedding requirements": bedding_entry}}} + # The preprocessor iterates its own ECONOMIC_MAP binding, while the bedding + # handler reads its entry from the mapping module; patch both. + monkeypatch.setattr(preprocessing, "ECONOMIC_MAP", economic_map) + monkeypatch.setattr(economics_mapping, "ECONOMIC_MAP", economic_map) + results = preprocessing.EconomicPreprocessor().preprocess() + return results["Animal"]["Costs"]["Bedding requirements"], dummy_om + + +def test_preprocess_bedding_bills_only_billable_pen_combinations(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, dummy_om = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "lac_and_growing_sand", "bedding_type": "sand"}, + ], + "straw_price": {"fips": [1001], "2021": [50.0]}, + "sand_price": {"fips": [1001], "2021": [120.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_3_LAC_COW": _daily(90, 365), + }, + pens=[_pen(0, "calf_straw"), _pen(3, "lac_and_growing_sand")], + type_to_key={"straw": "straw", "sand": "sand"}, + economics_files={"straw": "straw_price", "sand": "sand_price"}, + billable_pen_combinations=["LAC_COW"], + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(10800.0) + assert bedding["biophysical_aggregate"] == pytest.approx(90.0) + assert set(bedding["price_data"].keys()) == {"sand"} + emitted = dict(dummy_om.added_variables) + assert emitted["econ_bedding_total_cost"] == pytest.approx(10800.0) + assert emitted["econ_bedding_billed_head_years"] == pytest.approx(90.0) + assert emitted["econ_bedding_avg_price_per_head_year"] == pytest.approx(120.0) + + +def test_preprocess_bedding_pairs_each_pen_with_its_own_price(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "lac_and_growing_sand", "bedding_type": "sand"}, + ], + "straw_price": {"fips": [1001], "2021": [50.0]}, + "sand_price": {"fips": [1001], "2021": [120.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_2_GROWING": _daily(20, 365), + }, + pens=[_pen(1, "calf_straw"), _pen(2, "lac_and_growing_sand")], + type_to_key={"straw": "straw", "sand": "sand"}, + economics_files={"straw": "straw_price", "sand": "sand_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(2900.0) + assert bedding["flow_type"] == "cost" + assert set(bedding["price_data"].keys()) == {"straw", "sand"} + + +def test_preprocess_bedding_total_equals_quantity_times_price(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "lac_and_growing_sand", "bedding_type": "sand"}, + ], + "straw_price": {"fips": [1001], "2021": [50.0]}, + "sand_price": {"fips": [1001], "2021": [50.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_2_GROWING": _daily(20, 365), + }, + pens=[_pen(1, "calf_straw"), _pen(2, "lac_and_growing_sand")], + type_to_key={"straw": "straw", "sand": "sand"}, + economics_files={"straw": "straw_price", "sand": "sand_price"}, + ) + assert bedding["biophysical_aggregate"] == pytest.approx(30.0) + assert bedding["price_aggregate"] == pytest.approx(50.0) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(1500.0) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx( + bedding["biophysical_aggregate"] * bedding["price_aggregate"] + ) + + +def test_preprocess_bedding_resolves_by_pen_id_not_list_position(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "straw_cfg", "bedding_type": "straw"}, + {"name": "sand_cfg", "bedding_type": "sand"}, + {"name": "sawdust_cfg", "bedding_type": "sawdust"}, + ], + "straw_price": {"fips": [1001], "2021": [50.0]}, + "sand_price": {"fips": [1001], "2021": [120.0]}, + "sawdust_price": {"fips": [1001], "2021": [70.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_GROWING": _daily(20, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_2_CLOSE_UP": _daily(30, 365), + }, + pens=[_pen(2, "sawdust_cfg"), _pen(0, "straw_cfg"), _pen(1, "sand_cfg")], + type_to_key={"straw": "straw", "sand": "sand", "sawdust": "sawdust"}, + economics_files={"straw": "straw_price", "sand": "sand_price", "sawdust": "sawdust_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(5000.0) + + +def test_preprocess_bedding_normalizes_compound_types(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "cbpb_mix", "bedding_type": "CBPB sawdust"}, + {"name": "recycled", "bedding_type": "manure solids"}, + ], + "cbpb_price": {"fips": [1001], "2021": [10.0]}, + "ms_price": {"fips": [1001], "2021": [5.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_LAC_COW": _daily(2, 365), + }, + pens=[_pen(0, "cbpb_mix"), _pen(1, "recycled")], + type_to_key={"CBPB sawdust": "CBPB", "manure solids": "manure_solids"}, + economics_files={"CBPB": "cbpb_price", "manure_solids": "ms_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(110.0) + assert set(bedding["price_data"].keys()) == {"CBPB", "manure_solids"} + + +def test_preprocess_bedding_skips_none_type_with_no_cost(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, dummy_om = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "none (no bedding)", "bedding_type": "none"}, + ], + "straw_price": {"fips": [1001], "2021": [50.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_GROWING": _daily(99, 365), + }, + pens=[_pen(0, "calf_straw"), _pen(1, "none (no bedding)")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(500.0) + assert "UnmappedBeddingType" not in [code for code, _, _ in dummy_om.warnings] + + +def test_preprocess_bedding_uses_leap_year_denominator(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2020:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2020": [50.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(1, 366)}, + pens=[_pen(0, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(50.0) + + +def test_preprocess_bedding_prorates_partial_year(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2021": [365.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 20)}, + pens=[_pen(0, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(200.0) + + +def test_preprocess_bedding_falls_back_to_nearest_price_year(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, dummy_om = _run_bedding( + monkeypatch, + { + "config.start_date": "2018:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2021": [50.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365)}, + pens=[_pen(0, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(500.0) + assert "MissingPriceYear" in [code for code, _, _ in dummy_om.warnings] + + +def test_preprocess_bedding_warns_on_missing_fips(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, dummy_om = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 9999, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2021": [50.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365)}, + pens=[_pen(0, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert "MissingPriceData" in [code for code, _, _ in dummy_om.warnings] + + +def test_preprocess_bedding_unloadable_price_file_costs_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, dummy_om = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [ + {"name": "calf_straw", "bedding_type": "straw"}, + {"name": "lac_and_growing_sand", "bedding_type": "sand"}, + ], + "sand_price": {"fips": [1001], "2021": [120.0]}, + }, + { + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(10, 365), + "AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_1_GROWING": _daily(20, 365), + }, + pens=[_pen(0, "calf_straw"), _pen(1, "lac_and_growing_sand")], + type_to_key={"straw": "straw", "sand": "sand"}, + economics_files={"straw": "straw_price", "sand": "sand_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(2400.0) + assert "MissingBeddingPriceFile" in [code for code, _, _ in dummy_om.warnings] + + +def test_preprocess_bedding_pairs_each_year_with_its_own_price(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2020:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2020": [10.0], "2021": [20.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_0_CALF": _daily(5, 366 + 365)}, + pens=[_pen(0, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(150.0) + + +def test_preprocess_bedding_derives_pen_id_from_underscored_name(monkeypatch: pytest.MonkeyPatch) -> None: + bedding, _ = _run_bedding( + monkeypatch, + { + "config.start_date": "2021:1", + "config.FIPS_county_code": 1001, + "animal.bedding_configs": [{"name": "calf_straw", "bedding_type": "straw"}], + "straw_price": {"fips": [1001], "2021": [50.0]}, + }, + {"AnimalModuleReporter.report_daily_pen_total.number_of_animals_in_pen_7_CLOSE_UP": _daily(3, 365)}, + pens=[_pen(7, "calf_straw")], + type_to_key={"straw": "straw"}, + economics_files={"straw": "straw_price"}, + ) + assert bedding["line_item_values_by_scenario"]["baseline"] == pytest.approx(150.0) + + +def test_bedding_line_item_flows_into_framework_breakdown() -> None: + from RUFAS.EEE.economics.framework import EconomicFramework + + framework = EconomicFramework.__new__(EconomicFramework) + preprocessed = { + "Animal": { + "Costs": { + "Bedding requirements": { + "flow_type": "cost", + "line_item_values_by_scenario": {"baseline": 2900.0}, + "biophysical_values": [], + "price_values": [50.0, 120.0], + } + } + } + } + breakdown = framework._build_line_item_breakdown(preprocessed) + assert breakdown["Animal"]["costs"]["Bedding requirements"]["total"] == pytest.approx(2900.0) From 06bd4d6f64a8e23142aada1f5229172e00835760 Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Fri, 21 Aug 2026 22:56:47 +0900 Subject: [PATCH 2/2] Add changelog entry for PR 3219 --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index a097ff95b9..fcfb45a25b 100644 --- a/changelog.md +++ b/changelog.md @@ -24,6 +24,7 @@ A **changelog** is a structured record of changes made to the codebase over time v1.0.0 ### Next Version Updates +- [3219](https://github.com/RuminantFarmSystems/RuFaS/pull/3219) - [minor change] [Economics] [NoInputChange] [OutputChange] Port the issue #3088 bedding cost preprocessing onto the special-case handler architecture as BeddingRequirementsHandler, keeping per-pen pairing, lactating-cow-only billing, and identical output. - [2793](https://github.com/RuminantFarmSystems/RuFaS/pull/2793) - [minor change] [Animal] [OutputManager] [NoInputChange] [OutputChange] Track and summarize when effective DMI falls below the empirical domain of manure equations for lactating and dry cows at end of simulation. - [2865](https://github.com/RuminantFarmSystems/RuFaS/pull/2865) - [minor change] [NoInputChange] [NoOutputChange] Update to tables and formatting in scientific documentation. - [2727](https://github.com/RuminantFarmSystems/RuFaS/pull/2727) - [minor change] [NoInputChange] [NoOutputChange] Update the TaskType in Wiki, and fix the broken Sphinx action.