Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion RUFAS/EEE/economics/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -303,7 +345,7 @@
"map against the "
"commodity prices "
"here",
}
},
},
},
"Manure": {
Expand Down
149 changes: 147 additions & 2 deletions RUFAS/EEE/economics/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -660,13 +784,34 @@ 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, {})
category_data = section_data.setdefault(item.category, {})

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,
Expand Down
15 changes: 15 additions & 0 deletions RUFAS/biophysical/feed_storage/feed_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions tests/test_EEE/test_economics_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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