From b1c324127a123be6af6d3310509d1d27c8897e5d Mon Sep 17 00:00:00 2001 From: Allister Liu Date: Mon, 13 Jul 2026 14:02:01 -0400 Subject: [PATCH 1/3] [Econ] Anaerobic Digester CHP and RNG --- RUFAS/EEE/economics/digester_costs.py | 110 ++++++++++++++++++++++++++ tests/test_EEE/test_economics.py | 46 +++++++++++ 2 files changed, 156 insertions(+) diff --git a/RUFAS/EEE/economics/digester_costs.py b/RUFAS/EEE/economics/digester_costs.py index 981cd71bec..5cf25dd4cd 100644 --- a/RUFAS/EEE/economics/digester_costs.py +++ b/RUFAS/EEE/economics/digester_costs.py @@ -47,6 +47,41 @@ def annual_operating_costs(self, cows: float) -> Dict[str, float]: } +@dataclass(frozen=True) +class BiogasEnergyConversion: + """Conversion factors for turning captured biogas into usable energy. + + Parameters + ---------- + methane_content_fraction : float + Fraction of the total biogas volume that is methane (unitless). + methane_density_kg_per_m3 : float + Density used to convert methane volume to mass (kg CH4 / m^3 CH4). + methane_energy_content_kwh_per_kg : float + Energy content of methane fed into a combined heat and power unit (kWh / kg CH4). + chp_electrical_efficiency : float + Fraction of the methane energy input converted to electricity by the combined heat and power unit (unitless). + chp_thermal_efficiency : float + Fraction of the methane energy input recovered as heat by the combined heat and power unit (unitless). + rng_leakage_loss_fraction : float + Fraction of methane lost to leakage during renewable natural gas upgrading (unitless). + rng_process_heat_loss_fraction : float + Fraction of methane combusted to supply process heat for renewable natural gas upgrading (unitless). + rng_energy_yield_kwh_per_kg : float + Energy content of the upgraded renewable natural gas (kWh / kg RNG). + + """ + + methane_content_fraction: float = 0.60 + methane_density_kg_per_m3: float = 0.68 + methane_energy_content_kwh_per_kg: float = 13.89 + chp_electrical_efficiency: float = 0.33 + chp_thermal_efficiency: float = 0.43 + rng_leakage_loss_fraction: float = 0.02 + rng_process_heat_loss_fraction: float = 0.03 + rng_energy_yield_kwh_per_kg: float = 13.10 + + class DigesterCostCalculator: """Collection of digester-related economic calculations.""" @@ -273,3 +308,78 @@ def estimate_digester_trucking_cost(cls, cows: float) -> float: """Estimate the RNG trucking and injection cost for the herd size.""" return cls._TRANSPORT_COST.evaluate(cows) + + @staticmethod + def estimate_biogas_electricity( + biogas_volume_m3: float, + conversion: BiogasEnergyConversion = BiogasEnergyConversion(), + ) -> Dict[str, float]: + """Estimate electricity and heat produced from biogas by a combined heat and power unit. + + Parameters + ---------- + biogas_volume_m3 : float + Volume of captured biogas routed to the combined heat and power unit (m^3). + conversion : BiogasEnergyConversion + Conversion factors governing the methane content, energy content, and combined heat and power + efficiencies. + + Returns + ------- + Dict[str, float] + Mapping with the methane volume (m^3), methane mass (kg), methane energy input (kWh), electricity + output (kWh), and heat output (kWh). + + """ + + if biogas_volume_m3 < 0: + raise ValueError("biogas_volume_m3 must be non-negative") + + methane_volume = biogas_volume_m3 * conversion.methane_content_fraction + methane_mass = methane_volume * conversion.methane_density_kg_per_m3 + energy_input = methane_mass * conversion.methane_energy_content_kwh_per_kg + return { + "methane_volume_m3": methane_volume, + "methane_mass_kg": methane_mass, + "energy_input_kwh": energy_input, + "electricity_output_kwh": energy_input * conversion.chp_electrical_efficiency, + "heat_output_kwh": energy_input * conversion.chp_thermal_efficiency, + } + + @staticmethod + def estimate_biogas_rng( + biogas_volume_m3: float, + conversion: BiogasEnergyConversion = BiogasEnergyConversion(), + ) -> Dict[str, float]: + """Estimate renewable natural gas produced from biogas after upgrading. + + Parameters + ---------- + biogas_volume_m3 : float + Volume of captured biogas routed to renewable natural gas upgrading (m^3). + conversion : BiogasEnergyConversion + Conversion factors governing the methane content, upgrading losses, and renewable natural gas energy + yield. + + Returns + ------- + Dict[str, float] + Mapping with the methane volume before upgrading (m^3), the renewable natural gas volume (m^3), the + renewable natural gas mass (kg), and the renewable natural gas energy yield (kWh). + + """ + + if biogas_volume_m3 < 0: + raise ValueError("biogas_volume_m3 must be non-negative") + + methane_volume = biogas_volume_m3 * conversion.methane_content_fraction + methane_mass = methane_volume * conversion.methane_density_kg_per_m3 + retained_fraction = 1 - conversion.rng_leakage_loss_fraction - conversion.rng_process_heat_loss_fraction + rng_volume = methane_volume * retained_fraction + rng_mass = methane_mass * retained_fraction + return { + "methane_volume_m3": methane_volume, + "rng_volume_m3": rng_volume, + "rng_mass_kg": rng_mass, + "rng_energy_kwh": rng_mass * conversion.rng_energy_yield_kwh_per_kg, + } diff --git a/tests/test_EEE/test_economics.py b/tests/test_EEE/test_economics.py index 2710a9dd96..1e6b74752c 100644 --- a/tests/test_EEE/test_economics.py +++ b/tests/test_EEE/test_economics.py @@ -8,6 +8,7 @@ from RUFAS.EEE.economics.dcfror import DCFRORCalculator from RUFAS.EEE.economics.metrics import EconomicMetrics from RUFAS.EEE.economics.digester_costs import ( + BiogasEnergyConversion, DigesterCostCalculator, ) from RUFAS.EEE.economics.equations import EconomicEquations @@ -199,6 +200,51 @@ def test_estimate_digester_costs_linear_equations() -> None: assert pytest.approx(estimates["methane_content_fraction"]) == 0.6 +def test_estimate_biogas_electricity() -> None: + biogas_volume = 1000.0 + result = DigesterCostCalculator.estimate_biogas_electricity(biogas_volume) + + methane_volume = biogas_volume * 0.60 + methane_mass = methane_volume * 0.68 + energy_input = methane_mass * 13.89 + + assert pytest.approx(result["methane_volume_m3"]) == methane_volume + assert pytest.approx(result["methane_mass_kg"]) == methane_mass + assert pytest.approx(result["energy_input_kwh"]) == energy_input + assert pytest.approx(result["electricity_output_kwh"]) == energy_input * 0.33 + assert pytest.approx(result["heat_output_kwh"]) == energy_input * 0.43 + + +def test_estimate_biogas_rng() -> None: + biogas_volume = 1000.0 + result = DigesterCostCalculator.estimate_biogas_rng(biogas_volume) + + methane_volume = biogas_volume * 0.60 + retained_fraction = 1 - 0.02 - 0.03 + rng_volume = methane_volume * retained_fraction + rng_mass = rng_volume * 0.68 + + assert pytest.approx(result["methane_volume_m3"]) == methane_volume + assert pytest.approx(result["rng_volume_m3"]) == rng_volume + assert pytest.approx(result["rng_mass_kg"]) == rng_mass + assert pytest.approx(result["rng_energy_kwh"]) == rng_mass * 13.10 + + +def test_biogas_energy_conversion_overrides() -> None: + conversion = BiogasEnergyConversion(methane_content_fraction=0.5, chp_electrical_efficiency=0.4) + result = DigesterCostCalculator.estimate_biogas_electricity(100.0, conversion) + + energy_input = 100.0 * 0.5 * 0.68 * 13.89 + assert pytest.approx(result["electricity_output_kwh"]) == energy_input * 0.4 + + +def test_estimate_biogas_energy_rejects_negative_volume() -> None: + with pytest.raises(ValueError): + DigesterCostCalculator.estimate_biogas_electricity(-1.0) + with pytest.raises(ValueError): + DigesterCostCalculator.estimate_biogas_rng(-1.0) + + def test_get_digester_cost_profile_normalizes_names() -> None: profile = DigesterCostCalculator.get_digester_cost_profile("plug flow chp") From 6e0714a99e72518c87573d21797cfbb528b12427 Mon Sep 17 00:00:00 2001 From: Allister Liu Date: Tue, 28 Jul 2026 19:43:49 -0700 Subject: [PATCH 2/3] move conversion to Energy --- RUFAS/EEE/economics/mapping.py | 28 +-- RUFAS/EEE/economics/preprocessing.py | 144 +++++++++++- RUFAS/EEE/energy.py | 168 ++++++++++++++ RUFAS/data_validator.py | 16 +- RUFAS/units.py | 1 + input/data/EEE/economic_inputs.json | 47 ++-- input/data/EEE/economics_map.json | 14 +- ...ample_freestall_processor_connections.json | 9 + input/metadata/properties/default.json | 219 ++++++++++-------- 9 files changed, 500 insertions(+), 146 deletions(-) diff --git a/RUFAS/EEE/economics/mapping.py b/RUFAS/EEE/economics/mapping.py index 91dd2d0eb5..a6190c7d3e 100644 --- a/RUFAS/EEE/economics/mapping.py +++ b/RUFAS/EEE/economics/mapping.py @@ -469,30 +469,18 @@ "outputs", }, "Electricity production from anaerobic digester": { - "input_manager": ["economic_inputs.Manure.digester.kwh_per_day_produced"], + "biophysical_simulation": [r"Manure\.Digester\.energy\..*\.electricity_produced_kwh"], "economics_files": ["commodity_prices_elec_industrial_dollar_per_kwh"], - "future_expansion": "Placeholder " - "for " - "the " - "future, " - "scale " - "to " - "manure " - "module " - "outputs", + "aggregate_by_year": True, + "notes": "Daily electricity generated per digester (kWh) is summed by year and priced at the " + "industrial electricity rate for that year.", }, "Renewable natural gas (RNG) production": { - "input_manager": ["economic_inputs.Manure.digester.digester_rng_produced"], + "biophysical_simulation": [r"Manure\.Digester\.energy\..*\.rng_produced_megajoules"], "economics_files": ["commodity_prices_natgas_industrial_dollar_per_megajoule"], - "future_expansion": "Placeholder " - "for " - "the " - "future, " - "scale " - "to " - "manure " - "module " - "outputs", + "aggregate_by_year": True, + "notes": "Daily RNG generated per digester (MJ) is summed by year and priced at the industrial " + "natural gas rate for that year.", }, "Sold manure": { "input_manager": ["economic_inputs.Manure.manure_sales"], diff --git a/RUFAS/EEE/economics/preprocessing.py b/RUFAS/EEE/economics/preprocessing.py index 5c5d8f6159..db6a156366 100644 --- a/RUFAS/EEE/economics/preprocessing.py +++ b/RUFAS/EEE/economics/preprocessing.py @@ -16,7 +16,9 @@ import math import re +from collections import defaultdict from dataclasses import dataclass +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, Iterable, List, Set @@ -48,6 +50,7 @@ class EconomicItem: match_source: str | None wildcard_value_map: Dict[str, str] | None preprocessing: str | None + aggregate_by_year: bool class EconomicPreprocessor: @@ -168,6 +171,7 @@ 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") + aggregate_by_year = bool(details.get("aggregate_by_year", False)) if not biophysical_simulation and not input_manager and not economics_files: continue if isinstance(biophysical_simulation, str): @@ -185,6 +189,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, + aggregate_by_year=aggregate_by_year, ) ) return items @@ -356,6 +361,43 @@ def _expand_input_path_with_wildcards( return expanded_paths + def _resolve_input_path(self, path: str) -> Any: + """Resolve an InputManager path, expanding a list ancestor into a list of field values. + + A plain scalar or object path resolves exactly as ``InputManager.get_data`` would. When an + ancestor along the path is a list (e.g. ``economic_inputs.Manure.digester`` is now a list of + digesters), the trailing field is collected from every list element and returned as a list, so + downstream aggregation sums the field across all entries. + """ + parts = path.split(".") + for split_index in range(len(parts), 0, -1): + prefix = ".".join(parts[:split_index]) + value = self.im.get_data(prefix) + if value is None: + continue + + remaining = parts[split_index:] + if not remaining: + return value + + if isinstance(value, list): + collected: List[Any] = [] + for element in value: + current: Any = element + for key in remaining: + if isinstance(current, dict) and key in current: + current = current[key] + else: + current = None + break + if current is not None: + collected.append(current) + return collected + + # The prefix resolved to a non-list whose remaining keys did not resolve; treat as missing. + return None + return None + def _fetch_input_values( self, input_paths: Iterable[str], @@ -384,7 +426,7 @@ def _fetch_input_values( continue for candidate_path in candidate_paths: - data = self.im.get_data(candidate_path) + data = self._resolve_input_path(candidate_path) if data is None: self.om.add_warning( "MissingEconomicInput", @@ -638,6 +680,102 @@ def _fetch_prices_with_exact_matches( prices[option] = price_data return prices + def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: + """Compute revenue for a per-digester daily series by summing quantities per year and pricing each year. + + For each biophysical pattern (matching one variable per digester), the daily values are summed into + calendar-year buckets using each value's ``simulation_day``. Every year's quantity is multiplied by that + year's commodity price (falling back to the average price for years without an explicit entry), and the + results are summed into the total revenue line item. + """ + info_map = {"class": self.__class__.__name__, "function": self._compute_revenue_by_year.__name__} + + start_date_str = self.im.get_data("config.start_date") + end_date_str = self.im.get_data("config.end_date") + start_year: int | None = None + end_year: int | None = None + start_date: datetime | None = None + if start_date_str and end_date_str: + try: + start_year = int(str(start_date_str).split(":")[0]) + end_year = int(str(end_date_str).split(":")[0]) + start_date = datetime.strptime(str(start_date_str), "%Y:%j") + except (ValueError, AttributeError): + start_year = end_year = None + start_date = None + + price_data = self._fetch_prices(item.economics_files) + price_values = self._extract_price_values(price_data) + price_by_year: Dict[int, float] = {} + if start_year is not None and end_year is not None: + for offset, year in enumerate(range(start_year, end_year + 1)): + if offset < len(price_values): + price_by_year[year] = price_values[offset] + fallback_price = Aggregator.average(price_values) if price_values else None + if fallback_price is None: + fallback_price = ECONOMIC_PRICE_FALLBACK.get("revenue", 1.0) + + quantity_by_year: Dict[int, float] = defaultdict(float) + per_digester_quantity: Dict[str, float] = defaultdict(float) + name_pattern = re.compile(r"energy\.(.+?)\.[^.]+$") + + for path in item.biophysical_simulation: + filtered_pool = self.om.filter_variables_pool({"filters": [path]}) + for variable_name, payload in filtered_pool.items(): + if not isinstance(payload, dict): + continue + values = payload.get("values", []) + info_maps = payload.get("info_maps", []) + name_match = name_pattern.search(variable_name) + digester_name = name_match.group(1) if name_match else variable_name + for index, value in enumerate(values): + entry_info = ( + info_maps[index] if index < len(info_maps) and isinstance(info_maps[index], dict) else {} + ) + simulation_day = entry_info.get("simulation_day") + try: + numeric_value = float(value) + except (TypeError, ValueError): + continue + per_digester_quantity[digester_name] += numeric_value + if simulation_day is None or start_date is None: + # Without a day we cannot place the value in a year; fold it into the start year. + year = start_year if start_year is not None else 0 + else: + year = (start_date + timedelta(days=int(simulation_day))).year + quantity_by_year[year] += numeric_value + + revenue_by_year: Dict[int, float] = {} + total_revenue = 0.0 + for year, quantity in quantity_by_year.items(): + price = price_by_year.get(year, fallback_price) + year_revenue = quantity * price + revenue_by_year[year] = year_revenue + total_revenue += year_revenue + + total_quantity = sum(quantity_by_year.values()) + if not quantity_by_year: + self.om.add_warning( + "MissingDigesterEnergyOutputs", + f"No per-digester energy outputs matched patterns {item.biophysical_simulation} for '{item.name}'.", + info_map, + ) + + return { + "biophysical_values": [total_quantity], + "biophysical_aggregate": total_quantity, + "biophysical_values_by_scenario": {"baseline": [total_quantity]}, + "biophysical_aggregate_by_scenario": {"baseline": total_quantity}, + "price_data": price_data, + "price_values": price_values, + "price_aggregate": Aggregator.average(price_values) if price_values else None, + "line_item_values_by_scenario": {"baseline": total_revenue}, + "revenue_by_year": revenue_by_year, + "quantity_by_year": dict(quantity_by_year), + "per_digester_quantity": dict(per_digester_quantity), + "flow_type": "revenue", + } + def _aggregate(self, values: List[float], desc: str) -> float | None: """Aggregate values according to a textual description.""" if not values: @@ -670,6 +808,10 @@ 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.aggregate_by_year: + category_data[item.name] = self._compute_revenue_by_year(item) + 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/RUFAS/EEE/energy.py b/RUFAS/EEE/energy.py index b711bd3394..52f502d087 100644 --- a/RUFAS/EEE/energy.py +++ b/RUFAS/EEE/energy.py @@ -1,3 +1,4 @@ +import re from typing import Any from RUFAS.biophysical.field.crop.harvest_operations import HarvestOperation @@ -11,6 +12,11 @@ from RUFAS.EEE.tractor import Tractor from RUFAS.EEE.tractor_implement import TractorImplement +from RUFAS.EEE.economics.digester_costs import BiogasEnergyConversion, DigesterCostCalculator + +# 1 kWh equals 3.6 MJ; used to price RNG energy (reported in kWh by the conversion) against the +# natural-gas commodity price, which is expressed in dollars per megajoule. +KILOWATT_HOURS_TO_MEGAJOULES = 3.6 EEE_TO_OM_KEY_MAPPING = { FieldOperationEvent.PLANTING: { @@ -176,6 +182,168 @@ def estimate_all() -> None: total_diesel_consumption_tractor_implement_liter_per_ha, {**base_info_map, **{"units": MeasurementUnits.LITERS_PER_HA}}, ) + estimator.estimate_digester_energy_production() + print("a") + + def estimate_digester_energy_production(self) -> None: + """ + Estimates the daily electricity and renewable natural gas (RNG) generated by each anaerobic digester. + + For every digester configured under ``economic_inputs.Manure.digester``, the daily captured biogas volume + reported by the biophysical manure module is split between an RNG stream and an electricity stream according + to the digester's ``rng_ratio``. The split volumes are converted into delivered energy using + :meth:`~RUFAS.EEE.economics.digester_costs.DigesterCostCalculator.estimate_biogas_electricity` and + :meth:`~RUFAS.EEE.economics.digester_costs.DigesterCostCalculator.estimate_biogas_rng`. The per-day + electricity (kWh) and RNG (MJ) values are reported back to the :class:`~RUFAS.output_manager.OutputManager` + under a per-digester prefix so the economics module can aggregate them by year. + """ + info_map = { + "class": EnergyEstimator.__name__, + "function": EnergyEstimator.estimate_digester_energy_production.__name__, + } + digesters = im.get_data("economic_inputs.Manure.digester") + if not isinstance(digesters, list): + return + + conversion = BiogasEnergyConversion() + for digester in digesters: + if not isinstance(digester, dict): + continue + name = digester.get("name") + if not name: + om.add_warning( + "MissingDigesterName", + "A digester entry in 'economic_inputs.Manure.digester' has no 'name'; " + "cannot join it to captured biogas outputs.", + info_map, + ) + continue + rng_ratio = digester.get("rng_ratio", 0.0) + + biogas_payload = self._get_daily_captured_biogas(name) + if biogas_payload is None: + om.add_warning( + "MissingCapturedBiogas", + f"No captured biogas output found for digester '{name}'; skipping energy production.", + info_map, + ) + continue + + biogas_values = biogas_payload.get("values", []) + biogas_info_maps = biogas_payload.get("info_maps", []) + for index, captured_biogas_volume in enumerate(biogas_values): + simulation_day = None + if index < len(biogas_info_maps) and isinstance(biogas_info_maps[index], dict): + simulation_day = biogas_info_maps[index].get("simulation_day") + electricity_kwh, rng_megajoules = self.calculate_digester_energy_production( + captured_biogas_volume, rng_ratio, conversion + ) + self._report_digester_energy_production(name, electricity_kwh, rng_megajoules, simulation_day) + + def _get_daily_captured_biogas(self, digester_name: str) -> dict[str, Any] | None: + """ + Retrieves the daily captured biogas volume series for a digester from the ``OutputManager``. + + Parameters + ---------- + digester_name : str + The name of the digester, matching the biophysical manure processor name. + + Returns + ------- + dict[str, Any] | None + The pool payload (with ``values`` and ``info_maps``) for the digester's ``captured_biogas_volume``, or + ``None`` if no matching output exists. + """ + pattern = rf"\.{re.escape(digester_name)}\.captured_biogas_volume$" + filtered_pool = om.filter_variables_pool({"filters": [pattern]}) + if not filtered_pool: + return None + # A digester name is unique across processors, so at most one key is expected to match. + return next(iter(filtered_pool.values())) + + def calculate_digester_energy_production( + self, + captured_biogas_volume: float, + rng_ratio: float, + conversion: BiogasEnergyConversion | None = None, + ) -> tuple[float, float]: + """ + Converts a daily captured biogas volume into electricity and RNG using the RNG/electricity split ratio. + + Parameters + ---------- + captured_biogas_volume : float + The volume of biogas captured by the digester on a single day (m^3). + rng_ratio : float + Fraction of the captured biogas routed to RNG; the remainder ``(1 - rng_ratio)`` is routed to + electricity generation (unitless). + conversion : BiogasEnergyConversion | None + Conversion factors passed through to the digester cost calculator. Defaults to + :class:`~RUFAS.EEE.economics.digester_costs.BiogasEnergyConversion`. + + Returns + ------- + tuple[float, float] + The electricity generated (kWh) and the RNG generated (MJ) for the day. + + Notes + ----- + The captured biogas is split by ``rng_ratio`` into an RNG stream and a combined-heat-and-power (CHP) + electricity stream. Each stream is converted by the shared digester-cost calculator methods. The RNG energy, + returned in kilowatt-hours, is converted to megajoules to match the natural-gas commodity price basis. + """ + if conversion is None: + conversion = BiogasEnergyConversion() + + biogas_to_rng = captured_biogas_volume * rng_ratio + biogas_to_electricity = captured_biogas_volume * (1 - rng_ratio) + + electricity_result = DigesterCostCalculator.estimate_biogas_electricity(biogas_to_electricity, conversion) + rng_result = DigesterCostCalculator.estimate_biogas_rng(biogas_to_rng, conversion) + + electricity_kwh = electricity_result["electricity_output_kwh"] + rng_megajoules = rng_result["rng_energy_kwh"] * KILOWATT_HOURS_TO_MEGAJOULES + return electricity_kwh, rng_megajoules + + def _report_digester_energy_production( + self, + digester_name: str, + electricity_kwh: float, + rng_megajoules: float, + simulation_day: int | None, + ) -> None: + """ + Reports a digester's daily electricity and RNG production to the ``OutputManager``. + + Parameters + ---------- + digester_name : str + The name of the digester. + electricity_kwh : float + Electricity generated on the day (kWh). + rng_megajoules : float + RNG generated on the day (MJ). + simulation_day : int | None + The simulation day the values correspond to, used to aggregate by year downstream. + """ + base_info_map = { + "class": EnergyEstimator.__name__, + "function": EnergyEstimator.estimate_digester_energy_production.__name__, + "prefix": f"Manure.Digester.energy.{digester_name}", + } + om.add_variable( + "electricity_produced_kwh", + electricity_kwh, + {**base_info_map, "units": MeasurementUnits.KILOWATT_HOURS}, + simulation_day=simulation_day, + ) + om.add_variable( + "rng_produced_megajoules", + rng_megajoules, + {**base_info_map, "units": MeasurementUnits.MEGAJOULES}, + simulation_day=simulation_day, + ) def report_diesel_consumption( self, diff --git a/RUFAS/data_validator.py b/RUFAS/data_validator.py index 8a88ec30d3..87046558b1 100644 --- a/RUFAS/data_validator.py +++ b/RUFAS/data_validator.py @@ -2043,6 +2043,20 @@ def convert_variable_path_to_str(self, variable_path: list[str | int]) -> str: formatted_path_elems.append(f"{raw_path_elem}") return ".".join(formatted_path_elems) + @staticmethod + def _is_valid_list_index(key: str | int, length: int) -> bool: + """Return whether ``key`` is an in-range integer index for a list of ``length``. + + A non-numeric key (e.g. a field name applied to a list) is not a valid index and + yields ``False`` rather than raising, so callers extracting values by path receive a + clean ``KeyError`` (and, in turn, a ``None`` result) instead of a ``ValueError``. + """ + try: + index = int(key) + except (TypeError, ValueError): + return False + return 0 <= index < length + def extract_value_by_key_list( self, data: list[Any] | dict[str | int, Any], variable_path: Sequence[str | int], input_path: Path | None = None ) -> Any: @@ -2105,7 +2119,7 @@ def extract_value_by_key_list( """ for key in variable_path: - if isinstance(data, list) and 0 <= int(key) < len(data): + if isinstance(data, list) and self._is_valid_list_index(key, len(data)): data = data[int(key)] elif isinstance(data, dict) and isinstance(key, str) and key in data: data = data[key] diff --git a/RUFAS/units.py b/RUFAS/units.py index 62be75d54d..4766311192 100644 --- a/RUFAS/units.py +++ b/RUFAS/units.py @@ -59,6 +59,7 @@ class MeasurementUnits(Enum): KILOGRAMS_PER_MEGAGRAM = "kg/Mg" KILOGRAMS_PER_MILLIGRAM = "kg/mg" KILOMETERS = "km" + KILOWATT_HOURS = "kWh" L_ATM_PER_MOL_K = "L atm/mol/K" LITERS = "L" LITERS_PER_CUBIC_METER = "L/m^3" diff --git a/input/data/EEE/economic_inputs.json b/input/data/EEE/economic_inputs.json index 03da1e21da..88114259c2 100644 --- a/input/data/EEE/economic_inputs.json +++ b/input/data/EEE/economic_inputs.json @@ -12,26 +12,33 @@ "general": { "labor_hours": 1 }, - "slurry_storage": { - "labor_hours": 1, - "diesel_liters_per_day": 1, - "gasoline_liters_per_day": 1, - "megajoules_per_day": 1, - "kwh_per_day": 1, - "propane_liters_per_day": 1, - "cubic_meters_water_per_day": 1 - }, - "digester": { - "labor_hours_per_day": 1, - "diesel_liters_per_day": 1, - "gasoline_liters_per_day": 1, - "propane_liters_per_day": 1, - "megajoules_per_day": 1, - "kwh_per_day": 1, - "cubic_meters_water_per_day": 1, - "kwh_per_day_produced": 1, - "digester_rng_produced": 1 - }, + "slurry_storage": [ + { + "name": "slurry_storage_1", + "labor_hours": 1, + "diesel_liters_per_day": 1, + "gasoline_liters_per_day": 1, + "megajoules_per_day": 1, + "kwh_per_day": 1, + "propane_liters_per_day": 1, + "cubic_meters_water_per_day": 1 + } + ], + "digester": [ + { + "name": "anaerobic_digester_1", + "rng_ratio": 0.5, + "labor_hours_per_day": 1, + "diesel_liters_per_day": 1, + "gasoline_liters_per_day": 1, + "propane_liters_per_day": 1, + "megajoules_per_day": 1, + "kwh_per_day": 1, + "cubic_meters_water_per_day": 1, + "kwh_per_day_produced": 1, + "digester_rng_produced": 1 + } + ], "manure_disposal_kg": 1, "manure_disposal_price_per_kg": 1, "manure_disposal_km": 1, diff --git a/input/data/EEE/economics_map.json b/input/data/EEE/economics_map.json index f720410a1e..8f22f19b5f 100644 --- a/input/data/EEE/economics_map.json +++ b/input/data/EEE/economics_map.json @@ -367,22 +367,24 @@ "future_expansion": "Placeholder for the future, scale to manure module outputs" }, "Renewable natural gas (RNG) production": { - "input_manager": [ - "economic_inputs.Manure.digester.digester_rng_produced" + "biophysical_simulation": [ + "Manure\\.Digester\\.energy\\..*\\.rng_produced_megajoules" ], "economics_files": [ "commodity_prices.natgas_industrial.dollar_per_megajoule.csv" ], - "future_expansion": "Placeholder for the future, scale to manure module outputs" + "aggregate_by_year": true, + "notes": "Daily RNG generated per digester (MJ) is summed by year and priced at the industrial natural gas rate for that year." }, "Electricity production from anaerobic digester": { - "input_manager": [ - "economic_inputs.Manure.digester.kwh_per_day_produced" + "biophysical_simulation": [ + "Manure\\.Digester\\.energy\\..*\\.electricity_produced_kwh" ], "economics_files": [ "commodity_prices.elec_industrial.dollar_per_kwh.csv" ], - "future_expansion": "Placeholder for the future, scale to manure module outputs" + "aggregate_by_year": true, + "notes": "Daily electricity generated per digester (kWh) is summed by year and priced at the industrial electricity rate for that year." }, "Carbon credits from digester products and activities": { "biophysical_simulation": [ diff --git a/input/data/manure/example_freestall_processor_connections.json b/input/data/manure/example_freestall_processor_connections.json index 967b7e9033..89498c181d 100644 --- a/input/data/manure/example_freestall_processor_connections.json +++ b/input/data/manure/example_freestall_processor_connections.json @@ -38,6 +38,15 @@ }, { "processor_name": "parlor_cleaning_handler", + "destinations": [ + { + "receiving_processor_name": "anaerobic_digester_1", + "proportion": 1.0 + } + ] + }, + { + "processor_name": "anaerobic_digester_1", "destinations": [ { "receiving_processor_name": "slurry_storage_outdoor_lac", diff --git a/input/metadata/properties/default.json b/input/metadata/properties/default.json index 8517249e61..91a341ee8c 100644 --- a/input/metadata/properties/default.json +++ b/input/metadata/properties/default.json @@ -3183,107 +3183,130 @@ } }, "slurry_storage": { - "type": "object", - "description": "Daily resource use for slurry storages.", - "labor_hours": { - "type": "number", - "description": "Labor for slurry storage (hours/day).", - "minimum": 0, - "default": 0 - }, - "diesel_liters_per_day": { - "type": "number", - "description": "Diesel for slurry storage equipment (L/day).", - "minimum": 0, - "default": 0 - }, - "gasoline_liters_per_day": { - "type": "number", - "description": "Gasoline for slurry storage equipment (L/day).", - "minimum": 0, - "default": 0 - }, - "megajoules_per_day": { - "type": "number", - "description": "Natural-gas energy for slurry storage (MJ/day).", - "minimum": 0, - "default": 0 - }, - "kwh_per_day": { - "type": "number", - "description": "Electricity for slurry storage (kWh/day).", - "minimum": 0, - "default": 0 - }, - "propane_liters_per_day": { - "type": "number", - "description": "Propane burned daily by animal operations (L/day).", - "minimum": 0, - "default": 0 - }, - "cubic_meters_water_per_day": { - "type": "number", - "description": "Water used for slurry storage operations (m3/day).", - "minimum": 0, - "default": 0 + "type": "array", + "description": "A list of daily resource use configurations, one per slurry storage.", + "properties": { + "type": "object", + "description": "Daily resource use for a single slurry storage.", + "name": { + "type": "string", + "description": "Identifier of the slurry storage. Should match the corresponding manure processor name." + }, + "labor_hours": { + "type": "number", + "description": "Labor for slurry storage (hours/day).", + "minimum": 0, + "default": 0 + }, + "diesel_liters_per_day": { + "type": "number", + "description": "Diesel for slurry storage equipment (L/day).", + "minimum": 0, + "default": 0 + }, + "gasoline_liters_per_day": { + "type": "number", + "description": "Gasoline for slurry storage equipment (L/day).", + "minimum": 0, + "default": 0 + }, + "megajoules_per_day": { + "type": "number", + "description": "Natural-gas energy for slurry storage (MJ/day).", + "minimum": 0, + "default": 0 + }, + "kwh_per_day": { + "type": "number", + "description": "Electricity for slurry storage (kWh/day).", + "minimum": 0, + "default": 0 + }, + "propane_liters_per_day": { + "type": "number", + "description": "Propane burned daily by animal operations (L/day).", + "minimum": 0, + "default": 0 + }, + "cubic_meters_water_per_day": { + "type": "number", + "description": "Water used for slurry storage operations (m3/day).", + "minimum": 0, + "default": 0 + } } }, "digester": { - "type": "object", - "description": "Daily resource use and coproduct outputs for the anaerobic digester.", - "labor_hours_per_day": { - "type": "number", - "description": "Labor for digester operations (hours/day).", - "minimum": 0, - "default": 0 - }, - "diesel_liters_per_day": { - "type": "number", - "description": "Diesel used by the digester complex (L/day).", - "minimum": 0, - "default": 0 - }, - "gasoline_liters_per_day": { - "type": "number", - "description": "Gasoline used by the digester complex (L/day).", - "minimum": 0, - "default": 0 - }, - "propane_liters_per_day": { - "type": "number", - "description": "Propane used by the digester complex (L/day).", - "minimum": 0, - "default": 0 - }, - "megajoules_per_day": { - "type": "number", - "description": "Natural-gas energy consumed by the digester (MJ/day).", - "minimum": 0, - "default": 0 - }, - "kwh_per_day": { - "type": "number", - "description": "Electricity drawn by the digester (kWh/day).", - "minimum": 0, - "default": 0 - }, - "cubic_meters_water_per_day": { - "type": "number", - "description": "Water used by the digester (m3/day).", - "minimum": 0, - "default": 0 - }, - "kwh_per_day_produced": { - "type": "number", - "description": "Electricity exported from the digester (kWh/day).", - "minimum": 0, - "default": 0 - }, - "digester_rng_produced": { - "type": "number", - "description": "Renewable natural gas produced (MJ/day).", - "minimum": 0, - "default": 0 + "type": "array", + "description": "A list of daily resource use and coproduct configurations, one per anaerobic digester.", + "properties": { + "type": "object", + "description": "Daily resource use and coproduct outputs for a single anaerobic digester.", + "name": { + "type": "string", + "description": "Identifier of the digester. Should match the corresponding manure processor name so captured biogas can be joined." + }, + "rng_ratio": { + "type": "number", + "description": "Fraction of the digester's captured biogas routed to renewable natural gas (RNG); the remaining (1 - rng_ratio) is routed to electricity generation (unitless).", + "minimum": 0, + "maximum": 1, + "default": 0.5 + }, + "labor_hours_per_day": { + "type": "number", + "description": "Labor for digester operations (hours/day).", + "minimum": 0, + "default": 0 + }, + "diesel_liters_per_day": { + "type": "number", + "description": "Diesel used by the digester complex (L/day).", + "minimum": 0, + "default": 0 + }, + "gasoline_liters_per_day": { + "type": "number", + "description": "Gasoline used by the digester complex (L/day).", + "minimum": 0, + "default": 0 + }, + "propane_liters_per_day": { + "type": "number", + "description": "Propane used by the digester complex (L/day).", + "minimum": 0, + "default": 0 + }, + "megajoules_per_day": { + "type": "number", + "description": "Natural-gas energy consumed by the digester (MJ/day).", + "minimum": 0, + "default": 0 + }, + "kwh_per_day": { + "type": "number", + "description": "Electricity drawn by the digester (kWh/day).", + "minimum": 0, + "default": 0 + }, + "cubic_meters_water_per_day": { + "type": "number", + "description": "Water used by the digester (m3/day).", + "minimum": 0, + "default": 0 + }, + "kwh_per_day_produced": { + "type": "number", + "description": "Electricity exported from the digester (kWh/day).", + "minimum": 0, + "default": 0 + }, + "digester_rng_produced": { + "type": "number", + "description": "Renewable natural gas produced (MJ/day).", + "minimum": 0, + "default": 0 + } } }, "manure_disposal_kg": { From 8f7aab01f3cfd8703e0f1aa98c3c1a6006c00cd5 Mon Sep 17 00:00:00 2001 From: Allister Liu Date: Mon, 3 Aug 2026 13:11:32 -0700 Subject: [PATCH 3/3] feedback from econ work together --- RUFAS/EEE/economics/preprocessing.py | 51 +++++++++++++++----------- input/data/EEE/economic_inputs.json | 4 +- input/metadata/properties/default.json | 14 +------ 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/RUFAS/EEE/economics/preprocessing.py b/RUFAS/EEE/economics/preprocessing.py index db6a156366..94eb3fb6b7 100644 --- a/RUFAS/EEE/economics/preprocessing.py +++ b/RUFAS/EEE/economics/preprocessing.py @@ -706,7 +706,7 @@ def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: price_data = self._fetch_prices(item.economics_files) price_values = self._extract_price_values(price_data) - price_by_year: Dict[int, float] = {} + price_by_year: dict[int, float] = {} if start_year is not None and end_year is not None: for offset, year in enumerate(range(start_year, end_year + 1)): if offset < len(price_values): @@ -715,8 +715,8 @@ def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: if fallback_price is None: fallback_price = ECONOMIC_PRICE_FALLBACK.get("revenue", 1.0) - quantity_by_year: Dict[int, float] = defaultdict(float) - per_digester_quantity: Dict[str, float] = defaultdict(float) + bio_values_by_digester_by_year: dict[str, dict[int, list[float]]] = {} + bio_values_by_digester: dict[str, list[float]] = {} name_pattern = re.compile(r"energy\.(.+?)\.[^.]+$") for path in item.biophysical_simulation: @@ -728,6 +728,9 @@ def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: info_maps = payload.get("info_maps", []) name_match = name_pattern.search(variable_name) digester_name = name_match.group(1) if name_match else variable_name + if digester_name not in bio_values_by_digester: + bio_values_by_digester[digester_name] = [] + bio_values_by_digester_by_year[digester_name] = {year: [] for year in range(start_year, end_year + 1)} for index, value in enumerate(values): entry_info = ( info_maps[index] if index < len(info_maps) and isinstance(info_maps[index], dict) else {} @@ -737,24 +740,30 @@ def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: numeric_value = float(value) except (TypeError, ValueError): continue - per_digester_quantity[digester_name] += numeric_value + bio_values_by_digester[digester_name].append(numeric_value) if simulation_day is None or start_date is None: # Without a day we cannot place the value in a year; fold it into the start year. year = start_year if start_year is not None else 0 else: year = (start_date + timedelta(days=int(simulation_day))).year - quantity_by_year[year] += numeric_value + bio_values_by_digester_by_year[digester_name][year].append(numeric_value) - revenue_by_year: Dict[int, float] = {} + revenue_by_year: dict[str, dict[int, list[float]]] = {} + price_data_by_day: list[float] = [] total_revenue = 0.0 - for year, quantity in quantity_by_year.items(): - price = price_by_year.get(year, fallback_price) - year_revenue = quantity * price - revenue_by_year[year] = year_revenue - total_revenue += year_revenue - - total_quantity = sum(quantity_by_year.values()) - if not quantity_by_year: + for digester_name, year_quantities in bio_values_by_digester_by_year.items(): + if digester_name not in revenue_by_year: + revenue_by_year[digester_name] = {} + for year, quantity in year_quantities.items(): + if year not in revenue_by_year[digester_name]: + revenue_by_year[digester_name][year] = [] + price = price_by_year.get(year, fallback_price) + year_revenue: list[float] = [daily_quantity * price for daily_quantity in quantity] + revenue_by_year[digester_name][year] = year_revenue + price_data_by_day.extend([price] * len(year_revenue)) + total_revenue += sum(year_revenue) + + if not bio_values_by_digester_by_year: self.om.add_warning( "MissingDigesterEnergyOutputs", f"No per-digester energy outputs matched patterns {item.biophysical_simulation} for '{item.name}'.", @@ -762,17 +771,15 @@ def _compute_revenue_by_year(self, item: "EconomicItem") -> Dict[str, Any]: ) return { - "biophysical_values": [total_quantity], - "biophysical_aggregate": total_quantity, - "biophysical_values_by_scenario": {"baseline": [total_quantity]}, - "biophysical_aggregate_by_scenario": {"baseline": total_quantity}, + "biophysical_values": bio_values_by_digester, + "biophysical_aggregate": {digester_name: sum(bio_values)for digester_name, bio_values in bio_values_by_digester.items()}, + "biophysical_values_by_scenario": {"baseline": bio_values_by_digester}, + "biophysical_aggregate_by_scenario": {"baseline": {digester_name: sum(bio_values)for digester_name, bio_values in bio_values_by_digester.items()}}, "price_data": price_data, - "price_values": price_values, - "price_aggregate": Aggregator.average(price_values) if price_values else None, + "price_values": price_by_year, + "price_aggregate": Aggregator.average(price_data_by_day) if price_data_by_day else None, "line_item_values_by_scenario": {"baseline": total_revenue}, "revenue_by_year": revenue_by_year, - "quantity_by_year": dict(quantity_by_year), - "per_digester_quantity": dict(per_digester_quantity), "flow_type": "revenue", } diff --git a/input/data/EEE/economic_inputs.json b/input/data/EEE/economic_inputs.json index 88114259c2..228b425c5f 100644 --- a/input/data/EEE/economic_inputs.json +++ b/input/data/EEE/economic_inputs.json @@ -27,7 +27,6 @@ "digester": [ { "name": "anaerobic_digester_1", - "rng_ratio": 0.5, "labor_hours_per_day": 1, "diesel_liters_per_day": 1, "gasoline_liters_per_day": 1, @@ -35,8 +34,7 @@ "megajoules_per_day": 1, "kwh_per_day": 1, "cubic_meters_water_per_day": 1, - "kwh_per_day_produced": 1, - "digester_rng_produced": 1 + "kwh_electricity_to_rng_ratio": 0.5 } ], "manure_disposal_kg": 1, diff --git a/input/metadata/properties/default.json b/input/metadata/properties/default.json index 91a341ee8c..05a8088fd0 100644 --- a/input/metadata/properties/default.json +++ b/input/metadata/properties/default.json @@ -3246,7 +3246,7 @@ "type": "string", "description": "Identifier of the digester. Should match the corresponding manure processor name so captured biogas can be joined." }, - "rng_ratio": { + "kwh_electricity_to_rng_ratio": { "type": "number", "description": "Fraction of the digester's captured biogas routed to renewable natural gas (RNG); the remaining (1 - rng_ratio) is routed to electricity generation (unitless).", "minimum": 0, @@ -3294,18 +3294,6 @@ "description": "Water used by the digester (m3/day).", "minimum": 0, "default": 0 - }, - "kwh_per_day_produced": { - "type": "number", - "description": "Electricity exported from the digester (kWh/day).", - "minimum": 0, - "default": 0 - }, - "digester_rng_produced": { - "type": "number", - "description": "Renewable natural gas produced (MJ/day).", - "minimum": 0, - "default": 0 } } },