diff --git a/RUFAS/EEE/economics/mapping.py b/RUFAS/EEE/economics/mapping.py index 91dd2d0eb5..75573861ea 100644 --- a/RUFAS/EEE/economics/mapping.py +++ b/RUFAS/EEE/economics/mapping.py @@ -7,6 +7,36 @@ from typing import Any, Dict +# Alias map for homegrown feed crops that do not have a dedicated commodity +# price series. Each entry maps a feed_storage ``crop_name`` to the base name of +# an existing ``commodity_prices_{base}_dollar_per_kilogram`` series that serves +# as the closest available price proxy. These assignments were agreed with the +# economics SMEs (see issue #3079 / PR #3119 discussion): +# - cereal rye / triticale grain -> rye_grain +# - cereal rye / triticale / winter wheat silage +# and baleage -> barley_silage +# - tall fescue silage and baleage -> sundan_silage +# - alfalfa baleage -> alfalfa_silage +# - any hay other than alfalfa (per USDA, grass hays +# and other forages) -> hay_excluding_alfalfa +HOMEGROWN_FEED_PRICE_ALIASES: Dict[str, str] = { + "cereal_rye_grain": "rye_grain", + "cereal_rye_silage": "barley_silage", + "cereal_rye_baleage": "barley_silage", + "cereal_rye_hay": "hay_excluding_alfalfa", + "triticale_grain": "rye_grain", + "triticale_silage": "barley_silage", + "triticale_baleage": "barley_silage", + "triticale_hay": "hay_excluding_alfalfa", + "tall_fescue_silage": "sundan_silage", + "tall_fescue_baleage": "sundan_silage", + "tall_fescue_hay": "hay_excluding_alfalfa", + "winter_wheat_silage": "barley_silage", + "winter_wheat_baleage": "barley_silage", + "winter_wheat_hay": "hay_excluding_alfalfa", + "alfalfa_baleage": "alfalfa_silage", +} + ECONOMIC_MAP: Dict[str, Dict[str, Dict[str, Dict[str, Any]]]] = { "Animal": { "Costs": { @@ -127,6 +157,12 @@ "input_manager": ["economic_inputs.Feed_storage.labor_hours_per_day"], "economics_files": ["farm_services_labor_hours_dollar_per_hour"], }, + "Homegrown feed fed": { + "biophysical_simulation": ["FeedManager._log_feed_deductions.farmgrown_feed_.*_fed_dm"], + "use_feed_config_price_map": True, + "notes": "Cost of homegrown feed fed to animals, valued at commodity price per kg DM. " + "Wildcard matches each RuFaS feed ID; price is resolved automatically from feed_storage_configurations.", + }, "Purchased feed costs": { "biophysical_simulation": ["FeedManager.purchase_feed.ration_interval_.*_cost"], "economics_files": [ @@ -269,6 +305,12 @@ }, }, "Revenue": { + "Homegrown feed received": { + "biophysical_simulation": ["FeedManager.receive_crop.farmgrown_feed_.*_received"], + "use_feed_config_price_map": True, + "notes": "Revenue from homegrown crops received into storage, valued at commodity price per kg DM. " + "Wildcard matches each RuFaS feed ID; price is resolved automatically from feed_storage_configurations.", + }, "Feed_sales": { "biophysical_simulation": ["CropManagement._record_yield.harvest_yield.field='field_.*'"], "economics_files": [ @@ -303,7 +345,7 @@ "map against the " "commodity prices " "here", - } + }, }, }, "Manure": { diff --git a/RUFAS/EEE/economics/preprocessing.py b/RUFAS/EEE/economics/preprocessing.py index 5b718af977..bc0230202d 100644 --- a/RUFAS/EEE/economics/preprocessing.py +++ b/RUFAS/EEE/economics/preprocessing.py @@ -22,7 +22,7 @@ from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager from RUFAS.util import Aggregator -from RUFAS.EEE.economics.mapping import ECONOMIC_MAP +from RUFAS.EEE.economics.mapping import ECONOMIC_MAP, HOMEGROWN_FEED_PRICE_ALIASES from RUFAS.EEE.economics.fallback_values import ( BIOPHYSICAL_FALLBACKS, ECONOMIC_PRICE_FALLBACK, @@ -43,6 +43,7 @@ class EconomicItem: match_source: str | None wildcard_value_map: Dict[str, str] | None preprocessing: str | None + use_feed_config_price_map: bool class EconomicPreprocessor: @@ -163,7 +164,13 @@ def _build_mapping(self) -> List[EconomicItem]: economics_files = details.get("economics_files") match_source = details.get("match_source") wildcard_value_map = details.get("wildcard_value_map") - if not biophysical_simulation and not input_manager and not economics_files: + use_feed_config_price_map = bool(details.get("use_feed_config_price_map", False)) + if ( + not biophysical_simulation + and not input_manager + and not economics_files + and not use_feed_config_price_map + ): continue if isinstance(biophysical_simulation, str): biophysical_simulation = [biophysical_simulation] @@ -180,6 +187,7 @@ def _build_mapping(self) -> List[EconomicItem]: match_source=match_source, wildcard_value_map=wildcard_value_map if isinstance(wildcard_value_map, dict) else None, preprocessing=preprocessing, + use_feed_config_price_map=use_feed_config_price_map, ) ) return items @@ -633,6 +641,122 @@ def _fetch_prices_with_exact_matches( prices[option] = price_data return prices + def _resolve_price_file_key(self, crop_name: str) -> str | None: + """Resolve a feed ``crop_name`` to an available commodity price file key. + + Prefers an exact ``commodity_prices_{crop_name}_dollar_per_kilogram`` + match. When a feed crop has no dedicated commodity price series, falls + back to a curated alias (see + :data:`~RUFAS.EEE.economics.mapping.HOMEGROWN_FEED_PRICE_ALIASES`) that + points to the closest available proxy commodity, as agreed with the + economics SMEs. Returns ``None`` when neither a direct match nor a valid + alias resolves to an available InputManager key. + """ + direct_key = f"commodity_prices_{crop_name}_dollar_per_kilogram" + + # Without metadata we cannot validate keys; preserve the direct key so + # downstream lookups behave as before. + if not self.available_input_keys: + return direct_key + + if direct_key in self.available_input_keys: + return direct_key + + alias = HOMEGROWN_FEED_PRICE_ALIASES.get(crop_name) + if alias is not None: + alias_key = f"commodity_prices_{alias}_dollar_per_kilogram" + if alias_key in self.available_input_keys: + return alias_key + + return None + + def _build_feed_id_to_price_file_map(self) -> Dict[str, str]: + """Build a mapping of RuFaS feed ID to commodity price file key from feed storage configs. + + Reads ``feed_storage_configurations`` from the InputManager. Each storage entry + carries a ``rufas_id`` integer and a ``crop_name`` string. The commodity price + file key is resolved via :meth:`_resolve_price_file_key`, which prefers an exact + ``commodity_prices_{crop_name}_dollar_per_kilogram`` match and otherwise falls + back to a curated alias for feeds without a dedicated price series. + """ + info_map = {"class": self.__class__.__name__, "function": self._build_feed_id_to_price_file_map.__name__} + feed_id_map: Dict[str, str] = {} + try: + configs = self.im.get_data("feed_storage_configurations") + except Exception: + return feed_id_map + + if not isinstance(configs, dict): + return feed_id_map + + for storage_type_entries in configs.values(): + if not isinstance(storage_type_entries, list): + continue + for entry in storage_type_entries: + if not isinstance(entry, dict): + continue + rufas_id = entry.get("rufas_id") + crop_name = entry.get("crop_name") + if rufas_id is None or not isinstance(crop_name, str): + continue + price_file_key = self._resolve_price_file_key(crop_name) + if price_file_key is None: + self.om.add_warning( + "MissingHomegrownFeedPriceMapping", + f"No commodity price file or alias found for feed crop '{crop_name}' " + f"(feed ID '{rufas_id}')", + info_map, + ) + continue + feed_id_map[str(rufas_id)] = price_file_key + + return feed_id_map + + def _compute_line_items_by_wildcard( + self, + item: "EconomicItem", + wildcard_values: List[tuple], + feed_id_to_price_file: Dict[str, str], + ) -> Dict[str, float]: + """Compute per-wildcard-match line items using the feed config price map. + + For each captured wildcard group (i.e. a RuFaS feed ID), the method: + 1. Fetches the biophysical quantity for that specific feed ID. + 2. Resolves the commodity price CSV key from ``feed_id_to_price_file``. + 3. Returns a mapping of feed ID string to ``quantity * price`` line item. + """ + info_map = { + "class": self.__class__.__name__, + "function": self._compute_line_items_by_wildcard.__name__, + } + line_items: Dict[str, float] = {} + + for groups in wildcard_values: + if not groups: + continue + wildcard_value = str(groups[0]) + + specific_patterns = [re.sub(r"\.\*", wildcard_value, p) for p in item.biophysical_simulation] + quantity_values = self._fetch_values(specific_patterns) + quantity = self._aggregate(quantity_values, item.preprocessing or "") or 0.0 + + price_file_key = feed_id_to_price_file.get(wildcard_value) + if not price_file_key: + self.om.add_warning( + "MissingHomegrownFeedPriceMapping", + f"No commodity price file found for feed ID '{wildcard_value}' in feed storage configurations", + info_map, + ) + continue + + price_data = self._fetch_prices([price_file_key]) + price_values = self._extract_price_values(price_data) + price = self._aggregate(price_values, "average") or 0.0 + + line_items[wildcard_value] = quantity * price + + return line_items + def _aggregate(self, values: List[float], desc: str) -> float | None: """Aggregate values according to a textual description.""" if not values: @@ -660,6 +784,7 @@ def preprocess(self) -> Dict[str, Dict[str, Dict[str, Dict[str, Any]]]]: results: Dict[str, Dict[str, Dict[str, Dict[str, Any]]]] = {} info_map = {"class": self.__class__.__name__, "function": self.preprocess.__name__} + feed_id_to_price_file: Dict[str, str] = self._build_feed_id_to_price_file_map() for item in self.mapping: section_data = results.setdefault(item.section, {}) @@ -667,6 +792,26 @@ def preprocess(self) -> Dict[str, Dict[str, Dict[str, Dict[str, Any]]]]: values_by_scenario = self._fetch_values_by_scenario(item.biophysical_simulation) wildcard_values = self._collect_biophysical_wildcards(item.biophysical_simulation) + + if item.use_feed_config_price_map and wildcard_values: + per_wildcard_items = self._compute_line_items_by_wildcard(item, wildcard_values, feed_id_to_price_file) + if per_wildcard_items: + total = sum(per_wildcard_items.values()) + flow_type = self._infer_flow_type(item) or "cost" + category_data[item.name] = { + "biophysical_values": list(per_wildcard_items.values()), + "biophysical_aggregate": total, + "biophysical_values_by_scenario": {"baseline": list(per_wildcard_items.values())}, + "biophysical_aggregate_by_scenario": {"baseline": total}, + "price_data": {}, + "price_values": [], + "price_aggregate": None, + "line_item_values_by_scenario": {"baseline": total}, + "per_wildcard_line_items": per_wildcard_items, + "flow_type": flow_type, + } + continue + input_values, input_match_values = self._fetch_input_values( item.input_manager, wildcard_values, diff --git a/RUFAS/biophysical/feed_storage/feed_manager.py b/RUFAS/biophysical/feed_storage/feed_manager.py index 1a15b5578a..606ab8b5a7 100644 --- a/RUFAS/biophysical/feed_storage/feed_manager.py +++ b/RUFAS/biophysical/feed_storage/feed_manager.py @@ -320,6 +320,16 @@ def receive_crop( for storage in self.active_storages.values(): if storage.crop_name == crop_name and field_name in storage.field_names: storage.receive_crop(harvested_crop, simulation_day) + self._om.add_variable( + f"farmgrown_feed_{storage.rufas_feed_id}_received", + harvested_crop.dry_matter_mass, + { + "class": self.__class__.__name__, + "function": self.receive_crop.__name__, + "simulation_day": simulation_day, + "units": MeasurementUnits.DRY_KILOGRAMS, + }, + ) return else: info_map = { @@ -857,6 +867,11 @@ def _log_feed_deductions( }, info_map, ) + self._om.add_variable( + f"farmgrown_feed_{feed_id}_fed_dm", + amount, + info_map, + ) def _deduct_from_storage( self, diff --git a/tests/test_EEE/test_economics_preprocessing.py b/tests/test_EEE/test_economics_preprocessing.py index bc7fbde101..9a3b51178c 100644 --- a/tests/test_EEE/test_economics_preprocessing.py +++ b/tests/test_EEE/test_economics_preprocessing.py @@ -528,3 +528,85 @@ 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 _make_preprocessor(monkeypatch: pytest.MonkeyPatch, data=None): + dummy_im = DummyInputManager(data or {}) + dummy_om = DummyOutputManager({}) + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + return preprocessing.EconomicPreprocessor(), dummy_im, dummy_om + + +def test_resolve_price_file_key_prefers_direct_match(monkeypatch: pytest.MonkeyPatch) -> None: + preprocessor, _, _ = _make_preprocessor(monkeypatch) + preprocessor.available_input_keys = {"commodity_prices_corn_silage_dollar_per_kilogram"} + + assert preprocessor._resolve_price_file_key("corn_silage") == "commodity_prices_corn_silage_dollar_per_kilogram" + + +def test_resolve_price_file_key_uses_alias(monkeypatch: pytest.MonkeyPatch) -> None: + preprocessor, _, _ = _make_preprocessor(monkeypatch) + # cereal_rye_silage has no dedicated series; it should alias to barley_silage. + preprocessor.available_input_keys = {"commodity_prices_barley_silage_dollar_per_kilogram"} + + assert ( + preprocessor._resolve_price_file_key("cereal_rye_silage") + == "commodity_prices_barley_silage_dollar_per_kilogram" + ) + + +def test_resolve_price_file_key_returns_none_when_unmapped(monkeypatch: pytest.MonkeyPatch) -> None: + preprocessor, _, _ = _make_preprocessor(monkeypatch) + preprocessor.available_input_keys = {"commodity_prices_corn_silage_dollar_per_kilogram"} + + assert preprocessor._resolve_price_file_key("nonexistent_feed") is None + + +def test_resolve_price_file_key_without_metadata_returns_direct(monkeypatch: pytest.MonkeyPatch) -> None: + preprocessor, _, _ = _make_preprocessor(monkeypatch) + preprocessor.available_input_keys = set() + + assert ( + preprocessor._resolve_price_file_key("cereal_rye_silage") + == "commodity_prices_cereal_rye_silage_dollar_per_kilogram" + ) + + +def test_homegrown_feed_price_aliases_resolve_to_available_series(monkeypatch: pytest.MonkeyPatch) -> None: + """Every curated alias must point to a commodity series the resolver accepts.""" + preprocessor, _, _ = _make_preprocessor(monkeypatch) + available_targets = { + f"commodity_prices_{target}_dollar_per_kilogram" + for target in preprocessing.HOMEGROWN_FEED_PRICE_ALIASES.values() + } + preprocessor.available_input_keys = available_targets + + for crop_name, target in preprocessing.HOMEGROWN_FEED_PRICE_ALIASES.items(): + assert preprocessor._resolve_price_file_key(crop_name) == f"commodity_prices_{target}_dollar_per_kilogram" + + +def test_build_feed_id_map_applies_aliases_and_warns(monkeypatch: pytest.MonkeyPatch) -> None: + configs = { + "Silage": [ + {"rufas_id": 1, "crop_name": "corn_silage"}, # direct match + {"rufas_id": 2, "crop_name": "cereal_rye_silage"}, # alias -> barley_silage + ], + "Grain": [ + {"rufas_id": 3, "crop_name": "unmappable_feed"}, # no match, warns and skips + ], + } + preprocessor, _, dummy_om = _make_preprocessor(monkeypatch, {"feed_storage_configurations": configs}) + preprocessor.available_input_keys = { + "commodity_prices_corn_silage_dollar_per_kilogram", + "commodity_prices_barley_silage_dollar_per_kilogram", + } + + feed_id_map = preprocessor._build_feed_id_to_price_file_map() + + assert feed_id_map == { + "1": "commodity_prices_corn_silage_dollar_per_kilogram", + "2": "commodity_prices_barley_silage_dollar_per_kilogram", + } + warning_codes = [code for code, _, _ in dummy_om.warnings] + assert "MissingHomegrownFeedPriceMapping" in warning_codes