diff --git a/RUFAS/EEE/EEE_manager.py b/RUFAS/EEE/EEE_manager.py index 016d15dfd2..b835482bed 100644 --- a/RUFAS/EEE/EEE_manager.py +++ b/RUFAS/EEE/EEE_manager.py @@ -31,6 +31,7 @@ def estimate_all() -> None: "Failed to load runtime metadata for 'EEE_econ'. Aborting emissions estimation.", info_map, ) + print("Failed to load runtime metadata for 'EEE_econ'. Aborting emissions estimation.") return om.add_log("Economics Processing", "Starting processing of economics.", info_map) EconomicFramework().run_economic_analysis() diff --git a/RUFAS/EEE/economics/fallback_values.py b/RUFAS/EEE/economics/fallback_values.py index 00e7fbe535..52fa246b4f 100644 --- a/RUFAS/EEE/economics/fallback_values.py +++ b/RUFAS/EEE/economics/fallback_values.py @@ -19,6 +19,7 @@ "field.crop_specification": [20.0], "field.field_size": [30.0], "Waiting on tractor_implement and other parts of EEE outputs": [6.0], + "seed_cost": [0.0], } # Default quantity used when no biophysical or input values are available. diff --git a/RUFAS/EEE/economics/preprocessing.py b/RUFAS/EEE/economics/preprocessing.py index 5c5d8f6159..ced98b88e2 100644 --- a/RUFAS/EEE/economics/preprocessing.py +++ b/RUFAS/EEE/economics/preprocessing.py @@ -14,15 +14,18 @@ from __future__ import annotations +import json import math import re from dataclasses import dataclass +from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Dict, Iterable, List, Set +from typing import Any, Dict, Iterable, List, Set, Tuple +from RUFAS.biophysical.field.manager.schedule import Schedule from RUFAS.input_manager import InputManager from RUFAS.output_manager import OutputManager -from RUFAS.util import Aggregator +from RUFAS.util import Aggregator, Utility from RUFAS.EEE.economics.mapping import ECONOMIC_MAP from RUFAS.EEE.economics.fallback_values import ( BIOPHYSICAL_FALLBACKS, @@ -236,6 +239,27 @@ def _fetch_values(self, sim_paths: Iterable[str]) -> List[float]: ) return values + def _scenario_names(self) -> list[str]: + """Determine scenario names from the OutputManager variables pool. + + Returns + ------- + list[str] + Scenario names found as top-level keys of the variables pool, or + ``["baseline"]`` when the pool is flat (plain variable payloads) + or empty. + """ + scenario_names: list[str] = [] + pool = getattr(self.om, "variables_pool", {}) + if isinstance(pool, dict) and pool: + if all(isinstance(value, dict) and "values" in value for value in pool.values()): + scenario_names = ["baseline"] + else: + scenario_names = [name for name, data in pool.items() if isinstance(data, dict) and data] + if not scenario_names: + scenario_names = ["baseline"] + return scenario_names + def _fetch_values_by_scenario(self, sim_paths: Iterable[str]) -> Dict[str, List[float]]: """Collect values per scenario from the OutputManager.""" @@ -246,16 +270,7 @@ def _fetch_values_by_scenario(self, sim_paths: Iterable[str]) -> Dict[str, List[ fallback_values = self._fallback_values_by_scenario(sim_paths) return fallback_values - scenario_names: List[str] = [] - pool = getattr(self.om, "variables_pool", {}) - if isinstance(pool, dict) and pool: - if all(isinstance(value, dict) and "values" in value for value in pool.values()): - scenario_names = ["baseline"] - else: - scenario_names = [name for name, data in pool.items() if isinstance(data, dict) and data] - if not scenario_names: - scenario_names = ["baseline"] - + scenario_names = self._scenario_names() values_by_scenario: Dict[str, List[float]] = {scenario: [] for scenario in scenario_names} info_map = {"class": self.__class__.__name__, "function": self._fetch_values_by_scenario.__name__} @@ -638,6 +653,307 @@ def _fetch_prices_with_exact_matches( prices[option] = price_data return prices + _CROP_TO_SEED_KEY: Dict[str, str] = { + "corn_grain": "commodity_prices_corn_seed_dollar_per_square_meter", + "corn_silage": "commodity_prices_corn_seed_dollar_per_square_meter", + "soybean_grain": "commodity_prices_soybean_seed_dollar_per_square_meter", + "soybean_hay": "commodity_prices_soybean_seed_dollar_per_square_meter", + "winter_wheat_grain": "commodity_prices_wheat_seed_dollar_per_square_meter", + "winter_wheat_silage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "winter_wheat_baleage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "winter_wheat_hay": "commodity_prices_wheat_seed_dollar_per_square_meter", + "triticale_grain": "commodity_prices_wheat_seed_dollar_per_square_meter", + "triticale_silage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "triticale_baleage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "triticale_hay": "commodity_prices_wheat_seed_dollar_per_square_meter", + "cereal_rye_grain": "commodity_prices_wheat_seed_dollar_per_square_meter", + "cereal_rye_silage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "cereal_rye_baleage": "commodity_prices_wheat_seed_dollar_per_square_meter", + "cereal_rye_hay": "commodity_prices_wheat_seed_dollar_per_square_meter", + } + + _HA_TO_M2: float = 10_000.0 + + # Harvest operations that terminate a crop's life in the field. + _FINAL_HARVEST_OPS: frozenset[str] = frozenset({"harvest_kill", "kill_only"}) + + def _growing_periods( + self, schedule: Dict[str, Any] + ) -> List[Tuple[datetime, datetime]]: + """Return (planting_date, kill_date) pairs for one crop schedule entry. + + Pairs each planting event with its corresponding final harvest + (``harvest_kill`` or ``kill_only``). Intermediate ``harvest_only`` + operations are ignored. Pattern expansion (``pattern_repeat``, + ``planting_skip``, ``harvesting_skip``) is applied before pairing. + """ + pattern_repeat = int(schedule.get("pattern_repeat") or 0) + planting_skip = int(schedule.get("planting_skip") or 0) + harvesting_skip = int(schedule.get("harvesting_skip") or 0) + + raw_p_years: list[int] = list(schedule.get("planting_years") or []) + raw_p_days: list[int] = list(schedule.get("planting_days") or []) + raw_h_years: list[int] = list(schedule.get("harvest_years") or []) + raw_h_days: list[int] = list(schedule.get("harvest_days") or []) + raw_h_ops: list[str] = list(schedule.get("harvest_operations") or []) + + if not raw_p_years or not raw_h_years: + return [] + + p_years = Schedule.repeat_pattern(raw_p_years, planting_skip, pattern_repeat) + p_days = Utility.elongate_list(raw_p_days * (pattern_repeat + 1), len(p_years)) + h_years = Schedule.repeat_pattern(raw_h_years, harvesting_skip, pattern_repeat) + h_days = Utility.elongate_list(raw_h_days * (pattern_repeat + 1), len(h_years)) + h_ops = Utility.elongate_list(raw_h_ops * (pattern_repeat + 1), len(h_years)) + + events: List[Tuple[datetime, str]] = [] + for y, d in zip(p_years, p_days): + events.append((datetime.strptime(f"{y}:{d}", "%Y:%j"), "plant")) + for y, d, op in zip(h_years, h_days, h_ops): + events.append((datetime.strptime(f"{y}:{d}", "%Y:%j"), op)) + events.sort(key=lambda e: e[0]) + + periods: List[Tuple[datetime, datetime]] = [] + plant_date: datetime | None = None + for date, op in events: + if op == "plant": + plant_date = date + elif op in self._FINAL_HARVEST_OPS and plant_date is not None: + periods.append((plant_date, date)) + plant_date = None + return periods + + def _preprocess_seed_costs(self) -> dict[str, list[float]]: + """Build a daily time-series of responsible field area (m²) per seed key. + + For each field, each crop's planting-to-kill periods are located within + the simulation window. The field's area in m² is spread evenly over + each growing period (``field_size_m² / period_duration``), then + accumulated into a per-simulation-day array keyed by seed commodity. + + Returns + ------- + dict[str, list[float]] + Keys are seed commodity price keys; values are lists of length + ``total_sim_days`` where each element is the total m² for that + seed on that simulation day. + """ + info_map = {"class": self.__class__.__name__, "function": "_preprocess_seed_costs"} + + try: + start_date = datetime.strptime( + str(self.im.get_data("config.start_date")), "%Y:%j" + ) + end_date = datetime.strptime( + str(self.im.get_data("config.end_date")), "%Y:%j" + ) + except Exception: + self.om.add_warning( + "MissingConfigDates", + "Could not parse simulation start/end dates for seed cost preprocessing", + info_map, + ) + return {} + + total_sim_days: int = (end_date - start_date).days + 1 + + try: + field_keys = self.im.get_data_keys_by_properties("field_properties") + except Exception: + self.om.add_warning( + "MissingFieldData", + "Could not retrieve field keys for seed cost preprocessing", + info_map, + ) + return {} + + daily_area_by_seed: dict[str, list[float]] = {} + + for field_key in field_keys: + field_data = self.im.get_data(field_key) + if not isinstance(field_data, dict): + continue + crop_spec = field_data.get("crop_specification") + field_size_ha = field_data.get("field_size") + if crop_spec is None or field_size_ha is None: + continue + try: + field_size_m2 = float(field_size_ha) * self._HA_TO_M2 + except (TypeError, ValueError): + continue + + crop_schedules = self.im.get_data(f"{crop_spec}.crop_schedules") + if not isinstance(crop_schedules, list) or not crop_schedules: + self.om.add_warning( + "MissingCropSchedule", + f"No crop schedules found for '{crop_spec}' in field '{field_key}'", + info_map, + ) + continue + + for schedule in crop_schedules: + if not isinstance(schedule, dict): + continue + crop_species = schedule.get("crop_species") + if not isinstance(crop_species, str): + continue + seed_key = self._CROP_TO_SEED_KEY.get(crop_species, f"fallback_{crop_species}") + + if seed_key not in daily_area_by_seed.keys(): + daily_area_by_seed[seed_key] = [0.0] * total_sim_days + + growing_periods = self._growing_periods(schedule) + for plant_date, kill_date in growing_periods: + plant_idx = (plant_date - start_date).days + kill_idx = (kill_date - start_date).days + + # Clip to simulation window. + clipped_start = max(plant_idx, 0) + clipped_end = min(kill_idx, total_sim_days) + if clipped_start >= clipped_end: + continue + + duration = clipped_end - clipped_start + daily_value = field_size_m2 / duration + for i in range(clipped_start, clipped_end): + daily_area_by_seed[seed_key][i] += daily_value + + return daily_area_by_seed + + def _extract_daily_seed_price(self, price_data: Any) -> list[float]: + """Extract a per-simulation-day price series from seed pricing payloads. + + Yearly prices are resolved per commodity key (with fallback prices for + malformed payloads or missing years), then expanded so each simulation + day carries its year's price. + + Parameters + ---------- + price_data : Any + Mapping of commodity price keys to pricing payloads keyed by year + and FIPS code. + + Returns + ------- + list[float] + One price per simulation day, matching the length of the daily + area series produced by ``_preprocess_seed_costs``. + """ + info_map = {"class": self.__class__.__name__, "function": self._extract_daily_seed_price.__name__} + start_date = datetime.strptime(str(self.im.get_data("config.start_date")), "%Y:%j") + end_date = datetime.strptime(str(self.im.get_data("config.end_date")), "%Y:%j") + start_year: int = start_date.year + end_year: int = end_date.year + fips_code: int = int(self.im.get_data("config.FIPS_county_code")) + days_count = (end_date - start_date).days + 1 + + daily_seed_price: list[float] = [] + for key, value in price_data.items(): + fallback_prices: list[float] | None = None + price_by_year: dict[int, float] = {} + if "fallback" in key: + daily_seed_price = BIOPHYSICAL_FALLBACKS["seed_cost"] * days_count + break + if not isinstance(value, dict) or "fips" not in value or not isinstance(value["fips"], list): + self.om.add_warning( + "MissingPriceData", + f"Price data missing for key: {key}, FIPS: '{fips_code}' is not in expected format." + "Using fallback price.", + info_map, + ) + fallback_prices = self._get_fallback_price(start_year, end_year, key) + price_by_year = {start_year + i: price for i, price in enumerate(fallback_prices)} + else: + fips_idx = value["fips"].index(fips_code) + for year in range(start_year, end_year + 1): + try: + price_by_year[year] = value[f"{year}"][fips_idx] + except (KeyError, IndexError): + self.om.add_warning( + "MissingPriceData", + f"Price data missing for year '{year}' and FIPS '{fips_code}' in '{key}'." + "Using fallback price.", + info_map, + ) + if fallback_prices is None: + fallback_prices = self._get_fallback_price(start_year, end_year, key) + price_by_year[year] = fallback_prices[year - start_year] + for i in range(days_count): + daily_seed_price.append(price_by_year[(start_date + timedelta(days=i)).year]) + return daily_seed_price + + def _process_seed_costs_item(self) -> Dict[str, Any]: + """Build the full preprocessing result entry for the Seeds costs line item. + + Scenario names are resolved from the OutputManager variables pool the + same way ``preprocess`` does for regular line items. Field schedules + and sizes come from the InputManager and do not vary by scenario, so + every scenario receives the same seed cost values. + """ + + info_map = {"class": self.__class__.__name__, "function": "_process_seed_costs_item"} + + scenario_names = self._scenario_names() + + biophysical_values_by_scenario: dict[str, dict[str, list[float]]] = {} + biophysical_aggregate_by_scenario: dict[str, dict[str, float]] = {} + line_item_values_by_scenario: dict[str, float] = {} + + for scenario_name in scenario_names: + daily_area_by_seed = self._preprocess_seed_costs() + + biophysical_values: dict[str, list[float]] = {} + bio_total: dict[str, float] = {} + price_data: dict[str, list[float]] = {} + price_values: dict[str, list[float]] = {} + price_aggregate: dict[str, float] = {} + total_seed_cost = 0.0 + for seed_key, daily_area in daily_area_by_seed.items(): + raw_price = self._get_data_with_handling(seed_key, info_map) + if raw_price is None: + self.om.add_warning( + "MissingEconomicsFile", + f"Seed commodity pricing '{seed_key}' not found in InputManager, using fallback price.", + info_map, + ) + + extracted_prices = self._extract_daily_seed_price({seed_key: raw_price}) + if not extracted_prices: + continue + + daily_price_per_area = [ + seed_cost * area_m2 for seed_cost, area_m2 in zip(extracted_prices, daily_area) + ] + biophysical_values[seed_key] = daily_area_by_seed[seed_key] + price_values[seed_key] = extracted_prices + bio_total[seed_key] = sum(biophysical_values[seed_key]) + price_data[seed_key] = raw_price + price_aggregate[seed_key] = self._aggregate(extracted_prices, "average") + total_seed_cost += sum(daily_price_per_area) + + biophysical_values_by_scenario[scenario_name] = biophysical_values + biophysical_aggregate_by_scenario[scenario_name] = bio_total + line_item_values_by_scenario[scenario_name] = total_seed_cost + + if not daily_area_by_seed: + self.om.add_warning( + "MissingBiophysicalData", + "No field data found for seed cost preprocessing", + info_map, + ) + + return { + "biophysical_values": biophysical_values, + "biophysical_aggregate": bio_total, + "biophysical_values_by_scenario": biophysical_values_by_scenario, + "biophysical_aggregate_by_scenario": biophysical_aggregate_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", + } + def _aggregate(self, values: List[float], desc: str) -> float | None: """Aggregate values according to a textual description.""" if not values: @@ -670,6 +986,12 @@ def preprocess(self) -> Dict[str, Dict[str, Dict[str, Dict[str, Any]]]]: section_data = results.setdefault(item.section, {}) category_data = section_data.setdefault(item.category, {}) + if item.section == "Soil_and_crop" and item.name == "Seeds costs": + category_data[item.name] = self._process_seed_costs_item() + #TODO: remove this + json.dump(category_data[item.name], open(f"seed_cost_data.json", "w"), indent=4) + continue + values_by_scenario = self._fetch_values_by_scenario(item.biophysical_simulation) wildcard_values = self._collect_biophysical_wildcards(item.biophysical_simulation) input_values, input_match_values = self._fetch_input_values( diff --git a/tests/test_EEE/test_economics.py b/tests/test_EEE/test_economics.py index 2710a9dd96..21474bfcb8 100644 --- a/tests/test_EEE/test_economics.py +++ b/tests/test_EEE/test_economics.py @@ -277,44 +277,44 @@ def test_estimate_digester_trucking_cost() -> None: def test_equation_helpers() -> None: - years = construct_timeline(2, 5) + years = EconomicEquations.construct_timeline(2, 5) assert years.tolist() == [-1, 0, 1, 2, 3, 4, 5] - df = discount_factor(0.1, 2) + df = EconomicEquations.discount_factor(0.1, 2) assert pytest.approx(df) == 1 / (1.1**2) - assert pytest.approx(discount_factor(0.1, -1)) == 1 / 1.1 + assert pytest.approx(EconomicEquations.discount_factor(0.1, -1)) == 1 / 1.1 construction_rates = [0.5, 0.5] - cap = annual_capital_spent(100.0, construction_rates) + cap = EconomicEquations.annual_capital_spent(100.0, construction_rates) assert cap.tolist() == [50.0, 50.0] - equity = equity_contribution(100.0, construction_rates, 0.4) + equity = EconomicEquations.equity_contribution(100.0, construction_rates, 0.4) assert equity.tolist() == [20.0, 20.0] - lp = loan_principal(100.0, construction_rates, 0.6) + lp = EconomicEquations.loan_principal(100.0, construction_rates, 0.6) assert lp.tolist() == [30.0, 30.0] - ci = construction_interest(lp, 0.05) + ci = EconomicEquations.construction_interest(lp, 0.05) assert ci.tolist() == [1.5, 3.0] - npv_ci = npv_capital_plus_interest(cap, ci, 0.1, np.array([-1, 0])) - expected_npv = (cap + ci) * np.array([discount_factor(0.1, -1), discount_factor(0.1, 0)]) + npv_ci = EconomicEquations.npv_capital_plus_interest(cap, ci, 0.1, np.array([-1, 0])) + expected_npv = (cap + ci) * np.array([EconomicEquations.discount_factor(0.1, -1), EconomicEquations.discount_factor(0.1, 0)]) assert np.allclose(npv_ci, expected_npv) - payment = annual_loan_payment(1000.0, 0.05, 5, 0.8) + payment = EconomicEquations.annual_loan_payment(1000.0, 0.05, 5, 0.8) expected_payment = 1000.0 * 0.05 * 0.8 / (1 - (1 + 0.05) ** -5) assert pytest.approx(payment) == expected_payment - int_pay = interest_payment(800.0, 0.05) + int_pay = EconomicEquations.interest_payment(800.0, 0.05) assert pytest.approx(int_pay) == 40.0 - remaining = principal_after_payment(800.0, payment, int_pay) + remaining = EconomicEquations.principal_after_payment(800.0, payment, int_pay) assert pytest.approx(remaining) == 800.0 - payment + int_pay - dep = depreciation_schedule(1000.0, np.array([0.1, 0.2])) + dep = EconomicEquations.depreciation_schedule(1000.0, np.array([0.1, 0.2])) assert dep.tolist() == [100.0, 200.0] - nr = net_revenue(500.0, 200.0, 30.0, 20.0) + nr = EconomicEquations.net_revenue(500.0, 200.0, 30.0, 20.0) assert nr == 250.0 carried_loss = loss_carry_forward(-50.0, 0.0) @@ -326,14 +326,14 @@ def test_equation_helpers() -> None: taxable = taxable_income(nr, used_loss) assert taxable == 200.0 - tax = income_tax(taxable, 0.3) + tax = EconomicEquations.income_tax(taxable, 0.3) assert tax == 60.0 - aci = annual_cash_income(500.0, 200.0, 50.0, tax) + aci = EconomicEquations.annual_cash_income(500.0, 200.0, 50.0, tax) assert aci == 190.0 - pv = present_value(aci, discount_factor(0.1, 1)) + pv = EconomicEquations.present_value(aci, EconomicEquations.discount_factor(0.1, 1)) assert pytest.approx(pv) == aci / 1.1 - npv = net_present_value(np.array([pv]), np.array([10.0])) + npv = EconomicEquations.net_present_value(np.array([pv]), np.array([10.0])) assert pytest.approx(npv) == pv - 10.0 diff --git a/tests/test_EEE/test_economics_preprocessing.py b/tests/test_EEE/test_economics_preprocessing.py index 9228fe8899..27e5b93361 100644 --- a/tests/test_EEE/test_economics_preprocessing.py +++ b/tests/test_EEE/test_economics_preprocessing.py @@ -28,20 +28,26 @@ def add_log(self, title, message, info): class DummyInputManager: - def __init__(self, data): + def __init__(self, data, field_keys=None): self._data = { - "config.start_date": "2020:01:01", - "config.end_date": "2020:12:31", + "config.start_date": "2020:1", + "config.end_date": "2020:365", "config.FIPS_county_code": 1001, "_default_values": {"commodity": [], "2020": []}, "_default_fallback_values": {"commodity": [], "2020": []}, **data, } + self._field_keys = field_keys or [] self.added_runtime = [] def get_data(self, key): return self._data.get(key) + def get_data_keys_by_properties(self, properties_key): + if properties_key == "field_properties": + return self._field_keys + return [] + def add_runtime_variable_to_pool( self, variable_name, data, properties_blob_key, eager_termination=False, input_path=None ): @@ -531,3 +537,233 @@ 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 _seed_cost_map(): + """Minimal ECONOMIC_MAP for seed cost tests.""" + return { + "Soil_and_crop": { + "Costs": { + "Seeds costs": { + "biophysical_simulation": ["field.crop_specification", "field.field_size"], + "economics_files": ["commodity_prices_corn_seed_dollar_per_square_meter"], + } + } + } + } + + +def _corn_schedule(plant_day: int, kill_day: int, year: int = 2020) -> dict: + """Build a minimal corn_silage crop schedule dict for tests.""" + return { + "crop_species": "corn_silage", + "planting_years": [year], + "planting_days": [plant_day], + "harvest_years": [year], + "harvest_days": [kill_day], + "harvest_operations": ["harvest_kill"], + "pattern_repeat": 0, + "planting_skip": 0, + "harvesting_skip": 0, + } + + +def test_preprocess_seed_costs_daily_array_shape_and_values(monkeypatch: pytest.MonkeyPatch) -> None: + """Daily area array has length == sim days with correct values inside the growing period. + + Sim: 2020:1 → 2020:365 (365 days, indices 0-364) + field_a: 1 ha corn_silage, planted day 100, killed day 200. + plant_idx = 99, kill_idx = 199, duration = 100 + daily_value = 10,000 / 100 = 100.0 + Array is 100.0 for indices 99-198, 0.0 elsewhere. + """ + dummy_im = DummyInputManager( + data={ + "field_a": {"crop_specification": "RotA", "field_size": 1.0}, + "RotA.crop_schedules": [_corn_schedule(100, 200)], + "commodity_prices_corn_seed_dollar_per_square_meter": {"fips": [1001], "2020": [0.01]}, + }, + field_keys=["field_a"], + ) + dummy_om = DummyOutputManager({}) + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + + preprocessor = preprocessing.EconomicPreprocessor() + daily = preprocessor._preprocess_seed_costs() + + seed_key = "commodity_prices_corn_seed_dollar_per_square_meter" + assert seed_key in daily + arr = daily[seed_key] + assert len(arr) == 365 + assert arr[98] == pytest.approx(0.0) # day 99 (index 98) — before planting + assert arr[99] == pytest.approx(100.0) # planting day (index 99) + assert arr[198] == pytest.approx(100.0) # last day of growth (index 198) + assert arr[199] == pytest.approx(0.0) # harvest day — not included + assert sum(arr) == pytest.approx(10_000.0) # 100 days × 100 m²/day + + +def test_preprocess_seed_costs_computes_total_cost(monkeypatch: pytest.MonkeyPatch) -> None: + """Total seed cost = sum(daily_area) × avg_price across all fields and seed types. + + Sim: 2020:1 → 2020:365 + field_a: 1 ha corn_silage, day 100→200: sum = 10,000 m² + field_b: 2 ha corn_grain, day 50→150: sum = 20,000 m² + 2 ha alfalfa_silage (no seed key) — skipped + corn_seed price = $0.01/m² → total = 30,000 × 0.01 = $300 + """ + dummy_im = DummyInputManager( + data={ + "field_a": {"crop_specification": "RotA", "field_size": 1.0}, + "field_b": {"crop_specification": "RotB", "field_size": 2.0}, + "RotA.crop_schedules": [_corn_schedule(100, 200)], + "RotB.crop_schedules": [ + { + "crop_species": "corn_grain", + "planting_years": [2020], + "planting_days": [50], + "harvest_years": [2020], + "harvest_days": [150], + "harvest_operations": ["harvest_kill"], + "pattern_repeat": 0, + "planting_skip": 0, + "harvesting_skip": 0, + }, + { + "crop_species": "alfalfa_silage", + "planting_years": [2020], + "planting_days": [155], + "harvest_years": [2020], + "harvest_days": [300], + "harvest_operations": ["harvest_kill"], + "pattern_repeat": 0, + "planting_skip": 0, + "harvesting_skip": 0, + }, + ], + "commodity_prices_corn_seed_dollar_per_square_meter": {"fips": [1001], "2020": [0.01]}, + }, + field_keys=["field_a", "field_b"], + ) + dummy_om = DummyOutputManager({}) + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + monkeypatch.setattr(preprocessing, "ECONOMIC_MAP", _seed_cost_map()) + + preprocessor = preprocessing.EconomicPreprocessor() + results = preprocessor.preprocess() + + item = results["Soil_and_crop"]["Costs"]["Seeds costs"] + assert item["flow_type"] == "cost" + assert item["line_item_values_by_scenario"]["baseline"] == pytest.approx(300.0) + + +def test_preprocess_seed_costs_warns_on_missing_field_data(monkeypatch: pytest.MonkeyPatch) -> None: + """Seed costs handler emits warning when no field keys are available.""" + + dummy_im = DummyInputManager(data={}, field_keys=[]) + dummy_om = DummyOutputManager({}) + + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + monkeypatch.setattr(preprocessing, "ECONOMIC_MAP", _seed_cost_map()) + + preprocessor = preprocessing.EconomicPreprocessor() + results = preprocessor.preprocess() + + item = results["Soil_and_crop"]["Costs"]["Seeds costs"] + assert item["line_item_values_by_scenario"]["baseline"] == pytest.approx(0.0) + warning_codes = [code for code, _, _ in dummy_om.warnings] + assert "MissingBiophysicalData" in warning_codes + + +def test_preprocess_seed_costs_clips_period_to_simulation_window(monkeypatch: pytest.MonkeyPatch) -> None: + """Growing periods that extend beyond the simulation window are clipped. + + Sim: 2020:1 → 2020:365 (365 days, indices 0-364) + field: 1 ha corn_silage, planted day 300, killed day 100 of 2021. + plant_idx = 299, kill_idx = (2021_day100 - 2020_day1).days = 464 + Clipped range: [299, 365) → 66 days active + duration (unclipped) = 464 - 299 = 165 + daily_value = 10,000 / 165 ≈ 60.6 + sum(arr) ≈ 66 × 60.6 ≈ 4,000 (= 10,000 × 66/165) + """ + dummy_im = DummyInputManager( + data={ + "field_a": {"crop_specification": "RotA", "field_size": 1.0}, + "RotA.crop_schedules": [ + { + "crop_species": "corn_silage", + "planting_years": [2020], + "planting_days": [300], + "harvest_years": [2021], + "harvest_days": [100], + "harvest_operations": ["harvest_kill"], + "pattern_repeat": 0, + "planting_skip": 0, + "harvesting_skip": 0, + } + ], + }, + field_keys=["field_a"], + ) + dummy_om = DummyOutputManager({}) + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + + preprocessor = preprocessing.EconomicPreprocessor() + daily = preprocessor._preprocess_seed_costs() + + seed_key = "commodity_prices_corn_seed_dollar_per_square_meter" + arr = daily[seed_key] + assert len(arr) == 365 + assert arr[298] == pytest.approx(0.0) # before planting + + from datetime import datetime + + plant_date = datetime.strptime("2020:300", "%Y:%j") + kill_date = datetime.strptime("2021:100", "%Y:%j") + start_date = datetime.strptime("2020:1", "%Y:%j") + duration = (kill_date - plant_date).days + expected_daily = 10_000.0 / duration + expected_sum = 10_000.0 * 66 / duration + + assert arr[299] == pytest.approx(expected_daily) + assert arr[364] == pytest.approx(expected_daily) + assert sum(arr) == pytest.approx(expected_sum) + + +def test_process_seed_costs_handles_multiple_scenarios(monkeypatch: pytest.MonkeyPatch) -> None: + """Seed cost entry carries one value per scenario found in the OM pool. + + Field schedules do not vary by scenario, so every scenario receives the + same seed cost total (1 ha corn, 100 days × 100 m²/day × $0.01/m² = $100). + """ + dummy_im = DummyInputManager( + data={ + "field_a": {"crop_specification": "RotA", "field_size": 1.0}, + "RotA.crop_schedules": [_corn_schedule(100, 200)], + "commodity_prices_corn_seed_dollar_per_square_meter": {"fips": [1001], "2020": [0.01]}, + }, + field_keys=["field_a"], + ) + dummy_om = DummyOutputManager({}) + dummy_om.variables_pool = { + "baseline": {"some.variable": {"values": [1.0]}}, + "scenario_1": {"some.variable": {"values": [2.0]}}, + } + monkeypatch.setattr(preprocessing, "InputManager", lambda: dummy_im) + monkeypatch.setattr(preprocessing, "OutputManager", lambda: dummy_om) + monkeypatch.setattr(preprocessing, "ECONOMIC_MAP", _seed_cost_map()) + + preprocessor = preprocessing.EconomicPreprocessor() + results = preprocessor.preprocess() + + item = results["Soil_and_crop"]["Costs"]["Seeds costs"] + seed_key = "commodity_prices_corn_seed_dollar_per_square_meter" + assert set(item["line_item_values_by_scenario"]) == {"baseline", "scenario_1"} + assert set(item["biophysical_values_by_scenario"]) == {"baseline", "scenario_1"} + assert set(item["biophysical_aggregate_by_scenario"]) == {"baseline", "scenario_1"} + for scenario in ("baseline", "scenario_1"): + assert item["line_item_values_by_scenario"][scenario] == pytest.approx(100.0) + assert item["biophysical_aggregate_by_scenario"][scenario][seed_key] == pytest.approx(10_000.0)