From c6dfbcab4e2c7404fd86562ff77094f778d6d190 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:45:09 +0000 Subject: [PATCH 1/9] Initial plan From 807d1ac3f30521d3d06eb0f35e1218c3e8c67a70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:51:58 +0000 Subject: [PATCH 2/9] Add flexible temporal resolution support (year, month, day) Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- optimex/lca_processor.py | 170 +++++++++++++++++++++++++++++---------- optimex/utils.py | 64 +++++++++++++-- 2 files changed, 185 insertions(+), 49 deletions(-) diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 0b8287d..5d950e0 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -44,10 +44,44 @@ class TemporalResolutionEnum(str, Enum): Supported temporal resolutions for the optimization model. Attributes: - year: Annual time steps (currently the only supported resolution) + year: Annual time steps + month: Monthly time steps + day: Daily time steps """ year = "year" + month = "month" + day = "day" + + @property + def numpy_unit(self) -> str: + """Return the numpy timedelta64/datetime64 unit code for this resolution.""" + mapping = { + "year": "Y", + "month": "M", + "day": "D", + } + return mapping[self.value] + + @property + def pandas_freq(self) -> str: + """Return the pandas frequency string for date_range with this resolution.""" + mapping = { + "year": "YE", + "month": "ME", + "day": "D", + } + return mapping[self.value] + + @property + def pandas_offset(self) -> str: + """Return the pandas DateOffset attribute name for this resolution.""" + mapping = { + "year": "year", + "month": "month", + "day": "day", + } + return mapping[self.value] class CharacterizationMethodConfig(BaseModel): @@ -340,21 +374,30 @@ def _parse_demand(self) -> None: """ Parse and process the demand dictionary from the configuration. - This method transforms the demand data into a dictionary mapping (product_code, year) + This method transforms the demand data into a dictionary mapping (product_code, time_index) tuples to their corresponding amounts. It validates that demand is specified on foreground product nodes. Side Effects ------------ Updates the following instance attributes: - - self._demand: dict with keys (product_code, year) and values as amounts. + - self._demand: dict with keys (product_code, time_index) and values as amounts. - self._products: dict mapping product codes to product names. - - self._system_time: range of years covering the longest demand interval. + - self._system_time: range of time indices covering the longest demand interval. """ raw_demand = self.config.demand - start_year = self.config.temporal.start_date.year + resolution = self.config.temporal.temporal_resolution + start_date = self.config.temporal.start_date longest_demand_interval = 0 + # Compute the start index based on resolution + if resolution == TemporalResolutionEnum.year: + start_index = start_date.year + elif resolution == TemporalResolutionEnum.month: + start_index = start_date.year * 12 + start_date.month - 1 + else: # day + start_index = int(np.datetime64(start_date, 'D').astype(int)) + for product_node, td in raw_demand.items(): # Validate demand is on product nodes if not hasattr(product_node, 'key'): @@ -369,19 +412,30 @@ def _parse_demand(self) -> None: ) product_code = product_node['code'] - years = td.date.astype("datetime64[Y]").astype(int) + 1970 - if years[-1] - start_year > longest_demand_interval: - longest_demand_interval = years[-1] - start_year + + # Convert datetime64 to time indices based on resolution + numpy_unit = resolution.numpy_unit + if resolution == TemporalResolutionEnum.year: + time_indices = td.date.astype(f"datetime64[{numpy_unit}]").astype(int) + 1970 + elif resolution == TemporalResolutionEnum.month: + # Convert to months since epoch, then add offset to get absolute month index + dates_as_months = td.date.astype("datetime64[M]").astype(int) + time_indices = dates_as_months + 1970 * 12 # months since year 0 + else: # day + time_indices = td.date.astype("datetime64[D]").astype(int) + + if time_indices[-1] - start_index > longest_demand_interval: + longest_demand_interval = time_indices[-1] - start_index amounts = td.amount self._demand.update( - {(product_code, year): amount for year, amount in zip(years, amounts)} + {(product_code, idx): amount for idx, amount in zip(time_indices, amounts)} ) # Store product information self._products[product_code] = product_node['name'] - self._system_time = range(start_year, start_year + longest_demand_interval + 1) + self._system_time = range(start_index, start_index + longest_demand_interval + 1) logger.info( "Identified demand in system time range of %s for products %s", self._system_time, @@ -401,13 +455,13 @@ def _construct_foreground_tensors(self) -> None: Side Effects ----------- Updates the following instance attributes: - - self._foreground_technosphere: dict mapping (process_code, flow_code, year) + - self._foreground_technosphere: dict mapping (process_code, flow_code, time_index) to amount for external intermediate flows (background consumption). - - self._internal_demand_technosphere: dict mapping (process_code, product_code, year) + - self._internal_demand_technosphere: dict mapping (process_code, product_code, time_index) to amount for internal product consumption (foreground products). - - self._foreground_biosphere: dict mapping (process_code, flow_code, year) + - self._foreground_biosphere: dict mapping (process_code, flow_code, time_index) to amount for biosphere flows (emissions). - - self._foreground_production: dict mapping (process_code, product_code, year) + - self._foreground_production: dict mapping (process_code, product_code, time_index) to amount for product production. - self._products: dict mapping product codes to their names. - self._intermediate_flows: dict mapping background intermediate flow codes @@ -423,6 +477,9 @@ def _construct_foreground_tensors(self) -> None: internal_demand_technosphere = {} production_tensor = {} biosphere_tensor = {} + + resolution = self.config.temporal.temporal_resolution + numpy_unit = resolution.numpy_unit for act in self.foreground_db: # Only process nodes (not product nodes) @@ -439,18 +496,19 @@ def _construct_foreground_tensors(self) -> None: temporal_dist = exc.get( "temporal_distribution", TemporalDistribution( - date=np.array([0], dtype="timedelta64[Y]"), amount=np.array([1]) + date=np.array([0], dtype=f"timedelta64[{numpy_unit}]"), amount=np.array([1]) ), ) - years = temporal_dist.date.astype("timedelta64[Y]").astype(int) - # Ensure all years are included in process time + # Convert timedelta to process time indices based on resolution + time_indices = temporal_dist.date.astype(f"timedelta64[{numpy_unit}]").astype(int) + # Ensure all time indices are included in process time self._process_time.update( - year for year in years if year not in self._process_time + idx for idx in time_indices if idx not in self._process_time ) temporal_factor = temporal_dist.amount # Skip if temporal distribution is missing or invalid (empty arrays) - if years.size == 0 or temporal_factor.size == 0: + if time_indices.size == 0 or temporal_factor.size == 0: logger.debug( f"Skipping exchange {exc.input} due to missing or invalid temporal distribution.") continue @@ -464,8 +522,8 @@ def _construct_foreground_tensors(self) -> None: if edge_type == bd.labels.production_edge_default: product_code = input_code production_tensor.update({ - (act["code"], product_code, year): exc["amount"] * factor - for year, factor in zip(years, temporal_factor) + (act["code"], product_code, idx): exc["amount"] * factor + for idx, factor in zip(time_indices, temporal_factor) }) if exc.get("operation"): self._operation_flow.update({(act["code"], product_code): True}) @@ -476,8 +534,8 @@ def _construct_foreground_tensors(self) -> None: if input_db == self.foreground_db.name: # Internal demand: foreground product consumed internal_demand_technosphere.update({ - (act["code"], input_code, year): exc["amount"] * factor - for year, factor in zip(years, temporal_factor) + (act["code"], input_code, idx): exc["amount"] * factor + for idx, factor in zip(time_indices, temporal_factor) }) if exc.get("operation"): self._operation_flow.update({(act["code"], input_code): True}) @@ -485,8 +543,8 @@ def _construct_foreground_tensors(self) -> None: else: # External intermediate: background consumption technosphere_tensor.update({ - (act["code"], input_code, year): exc["amount"] * factor - for year, factor in zip(years, temporal_factor) + (act["code"], input_code, idx): exc["amount"] * factor + for idx, factor in zip(time_indices, temporal_factor) }) if exc.get("operation"): self._operation_flow.update({(act["code"], input_code): True}) @@ -495,8 +553,8 @@ def _construct_foreground_tensors(self) -> None: # Handle biosphere edges elif edge_type == bd.labels.biosphere_edge_default: biosphere_tensor.update({ - (act["code"], input_code, year): exc["amount"] * factor - for year, factor in zip(years, temporal_factor) + (act["code"], input_code, idx): exc["amount"] * factor + for idx, factor in zip(time_indices, temporal_factor) }) if exc.get("operation"): self._operation_flow.update({(act["code"], input_code): True}) @@ -512,10 +570,10 @@ def _construct_foreground_tensors(self) -> None: def log_tensor_dimensions(tensor, name): processes = {k[0] for k in tensor} flows = {k[1] for k in tensor} - years = {k[2] for k in tensor} + time_points = {k[2] for k in tensor} logger.info( f"{name} shape: ({len(processes)} processes, {len(flows)} flows, " - f"{len(years)} years) with {len(tensor)} total entries." + f"{len(time_points)} time points) with {len(tensor)} total entries." ) logger.info("Constructed foreground tensors.") @@ -784,7 +842,7 @@ def _construct_characterization_tensor(self) -> None: Construct the characterization tensor for LCIA methods over system time points. This method computes characterization factors for elementary flows across all - system years, supporting both static and dynamic methods. It handles metrics + system time points, supporting both static and dynamic methods. It handles metrics like Global Warming Potential (GWP) and Cumulative Radiative Forcing (CRF) when dynamic characterization is requested. @@ -792,13 +850,21 @@ def _construct_characterization_tensor(self) -> None: ----------- Updates the following instance attribute: - self._characterization: dict mapping (method_name, elementary_flow_code, - system_year) to characterization factor values. + system_time_index) to characterization factor values. """ start_date = self.config.temporal.start_date time_horizon = self.config.temporal.time_horizon + resolution = self.config.temporal.temporal_resolution + pandas_freq = resolution.pandas_freq + dates = pd.date_range( - start=start_date, periods=len(self._system_time), freq="YE" + start=start_date, periods=len(self._system_time), freq=pandas_freq ) + + # Create mapping from system time indices to dates + system_time_list = sorted(self._system_time) + time_index_to_date = dict(zip(system_time_list, dates)) + flow_codes = list(self.elementary_flows.keys()) # Pre-map flow codes to Brightway flow IDs @@ -827,16 +893,16 @@ def _construct_characterization_tensor(self) -> None: for _, row in df.iterrows(): flow_code, flow_id = row["code"], row["flow"] if flow_id in method_dict: - for year in dates.year: + for time_idx in system_time_list: characterization_tensor[ - (category_name, flow_code, year) + (category_name, flow_code, time_idx) ] = method_dict[flow_id] logger.info( f"Static characterization for method {category_name} completed." ) elif metric == "GWP": - # Dynamic GWP (year-specific values) + # Dynamic GWP (time-specific values) df = df.loc[np.repeat(df.index, len(dates))].reset_index(drop=True) df["date"] = np.tile(dates, len(flow_codes)) df["date"] = df["date"].astype("datetime64[s]") @@ -848,11 +914,18 @@ def _construct_characterization_tensor(self) -> None: base_lcia_method=method, time_horizon=time_horizon, ) - df_char["date"] = df_char["date"].dt.year + + # Map dates back to system time indices based on resolution + if resolution == TemporalResolutionEnum.year: + df_char["time_idx"] = df_char["date"].dt.year + elif resolution == TemporalResolutionEnum.month: + df_char["time_idx"] = df_char["date"].dt.year * 12 + df_char["date"].dt.month - 1 + else: # day + df_char["time_idx"] = (df_char["date"] - pd.Timestamp("1970-01-01")).dt.days for _, row in df_char.iterrows(): flow_code = df.loc[df["flow"] == row["flow"], "code"].values[0] - characterization_tensor[(category_name, flow_code, row["date"])] = ( + characterization_tensor[(category_name, flow_code, row["time_idx"])] = ( row["amount"] ) logger.info( @@ -878,10 +951,25 @@ def _construct_characterization_tensor(self) -> None: ) rf_series = df_char["amount"].values - for year in self.system_time: - cutoff = start_date.year + time_horizon - year - 1 - cumulative_rf = rf_series[:cutoff].sum() - characterization_tensor[(category_name, flow_code, year)] = ( + for time_idx in system_time_list: + # Compute cutoff based on resolution + if resolution == TemporalResolutionEnum.year: + cutoff = start_date.year + time_horizon - time_idx - 1 + elif resolution == TemporalResolutionEnum.month: + # Convert time_idx to year for cutoff calculation + start_month_idx = start_date.year * 12 + start_date.month - 1 + months_elapsed = time_idx - start_month_idx + years_elapsed = months_elapsed / 12.0 + cutoff = int(time_horizon - years_elapsed - 1) + else: # day + start_day_idx = int(np.datetime64(start_date, 'D').astype(int)) + days_elapsed = time_idx - start_day_idx + years_elapsed = days_elapsed / 365.25 + cutoff = int(time_horizon - years_elapsed - 1) + + cutoff = max(0, cutoff) # Ensure non-negative + cumulative_rf = rf_series[:cutoff].sum() if cutoff > 0 else 0.0 + characterization_tensor[(category_name, flow_code, time_idx)] = ( cumulative_rf ) logger.info( diff --git a/optimex/utils.py b/optimex/utils.py index ceb0979..e0c944c 100644 --- a/optimex/utils.py +++ b/optimex/utils.py @@ -8,10 +8,26 @@ from bw_temporalis import TemporalDistribution, easy_timedelta_distribution -def infer_operation_td_from_limits(node: bd.backends.proxies.Activity): +def infer_operation_td_from_limits( + node: bd.backends.proxies.Activity, + resolution: str = "Y" +): """ Infer a temporal distribution for the operation of a process node based on its operation_time_limits. + + Parameters + ---------- + node : bd.backends.proxies.Activity + The Brightway activity node with operation_time_limits attribute. + resolution : str, optional + Temporal resolution for the distribution. Options: "Y" (year), "M" (month), "D" (day). + Default is "Y". + + Returns + ------- + TemporalDistribution or None + A temporal distribution object, or None if limits are not defined. """ limits = node.get("operation_time_limits") @@ -28,14 +44,30 @@ def infer_operation_td_from_limits(node: bd.backends.proxies.Activity): end=end, steps=num_steps, kind="uniform", - resolution="Y", + resolution=resolution, ) -def infer_eol_td_from_limits(node: bd.backends.proxies.Activity): +def infer_eol_td_from_limits( + node: bd.backends.proxies.Activity, + resolution: str = "Y" +): """ Infer a temporal distribution for the end-of-life of a process node based on its - operation_time_limits, assuming EOL occurs one year after operation ends. + operation_time_limits, assuming EOL occurs one time unit after operation ends. + + Parameters + ---------- + node : bd.backends.proxies.Activity + The Brightway activity node with operation_time_limits attribute. + resolution : str, optional + Temporal resolution for the distribution. Options: "Y" (year), "M" (month), "D" (day). + Default is "Y". + + Returns + ------- + TemporalDistribution or None + A temporal distribution object, or None if limits are not defined. """ limits = node.get("operation_time_limits") @@ -45,14 +77,30 @@ def infer_eol_td_from_limits(node: bd.backends.proxies.Activity): _, end = limits return TemporalDistribution( - date=np.array([end + 1], dtype="timedelta64[Y]"), amount=np.array([1]) + date=np.array([end + 1], dtype=f"timedelta64[{resolution}]"), amount=np.array([1]) ) -def infer_construction_td_from_limits(node: bd.backends.proxies.Activity): +def infer_construction_td_from_limits( + node: bd.backends.proxies.Activity, + resolution: str = "Y" +): """ Infer a temporal distribution for the construction of a process node based on its - operation_time_limits, assuming construction occurs one year before operation starts. + operation_time_limits, assuming construction occurs at the start of operation. + + Parameters + ---------- + node : bd.backends.proxies.Activity + The Brightway activity node with operation_time_limits attribute. + resolution : str, optional + Temporal resolution for the distribution. Options: "Y" (year), "M" (month), "D" (day). + Default is "Y". + + Returns + ------- + TemporalDistribution or None + A temporal distribution object, or None if limits are not defined. """ limits = node.get("operation_time_limits") @@ -62,5 +110,5 @@ def infer_construction_td_from_limits(node: bd.backends.proxies.Activity): start, _ = limits return TemporalDistribution( - date=np.array([start], dtype="timedelta64[Y]"), amount=np.array([1]) + date=np.array([start], dtype=f"timedelta64[{resolution}]"), amount=np.array([1]) ) From c4cb561be5e4387812023682f864ff648ab8e35d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:54:59 +0000 Subject: [PATCH 3/9] Add monthly resolution tests and example notebook Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- notebooks/monthly_resolution_example.ipynb | 585 +++++++++++++++++++++ tests/test_temporal_resolution.py | 304 +++++++++++ 2 files changed, 889 insertions(+) create mode 100644 notebooks/monthly_resolution_example.ipynb create mode 100644 tests/test_temporal_resolution.py diff --git a/notebooks/monthly_resolution_example.ipynb b/notebooks/monthly_resolution_example.ipynb new file mode 100644 index 0000000..f6b334c --- /dev/null +++ b/notebooks/monthly_resolution_example.ipynb @@ -0,0 +1,585 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "89a21041", + "metadata": {}, + "source": [ + "# Monthly Resolution Example: Seasonal Renewable Electricity\n", + "\n", + "This notebook demonstrates `optimex`'s support for **monthly temporal resolution**. It models a scenario where:\n", + "\n", + "1. **Renewable electricity availability varies seasonally** - solar PV generates more in summer months\n", + "2. **Water use characterization factors vary monthly** - water scarcity is higher in dry summer months\n", + "3. **Demand is constant** - showing how the optimizer handles seasonal supply variations\n", + "\n", + "This example showcases the new flexible temporal resolution feature that enables sub-yearly optimization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f212141", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "import numpy as np\n", + "import bw2data as bd\n", + "from bw_temporalis import TemporalDistribution\n", + "\n", + "bd.projects.set_current(\"monthly_resolution_example\")" + ] + }, + { + "cell_type": "markdown", + "id": "d3759076", + "metadata": {}, + "source": [ + "## 1. Setup Brightway Databases\n", + "\n", + "### Biosphere Database\n", + "\n", + "We define elementary flows for CO2 emissions and water consumption." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e374c20", + "metadata": {}, + "outputs": [], + "source": [ + "# BIOSPHERE\n", + "biosphere_data = {\n", + " (\"biosphere3\", \"CO2\"): {\n", + " \"type\": \"emission\",\n", + " \"name\": \"carbon dioxide\",\n", + " \"CAS number\": \"000124-38-9\"\n", + " },\n", + " (\"biosphere3\", \"water\"): {\n", + " \"type\": \"emission\",\n", + " \"name\": \"water consumption\",\n", + " },\n", + "}\n", + "bd.Database(\"biosphere3\").write(biosphere_data)" + ] + }, + { + "cell_type": "markdown", + "id": "08177bd9", + "metadata": {}, + "source": [ + "### Background Database\n", + "\n", + "Simple background processes for grid electricity and natural gas." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c8ced0d", + "metadata": {}, + "outputs": [], + "source": [ + "# BACKGROUND DATABASE\n", + "background_data = {\n", + " (\"background\", \"grid_electricity\"): {\n", + " \"name\": \"Grid electricity\",\n", + " \"location\": \"EU\",\n", + " \"reference product\": \"electricity\",\n", + " \"exchanges\": [\n", + " {\"amount\": 1, \"type\": \"production\", \"input\": (\"background\", \"grid_electricity\")},\n", + " {\"amount\": 0.5, \"type\": \"biosphere\", \"input\": (\"biosphere3\", \"CO2\")}, # 500g CO2/kWh\n", + " {\"amount\": 0.01, \"type\": \"biosphere\", \"input\": (\"biosphere3\", \"water\")}, # 10L water/kWh\n", + " ],\n", + " },\n", + "}\n", + "bd.Database(\"background\").write(background_data)\n", + "bd.Database(\"background\").metadata[\"representative_time\"] = datetime(2024, 1, 1).isoformat()" + ] + }, + { + "cell_type": "markdown", + "id": "cffa77cb", + "metadata": {}, + "source": [ + "### Foreground Database: Electricity Production Options\n", + "\n", + "We model two electricity production options:\n", + "\n", + "1. **Solar PV** - Higher production in summer months (seasonal pattern)\n", + "2. **Conventional (gas backup)** - Constant production year-round\n", + "\n", + "The key innovation is that we use **monthly temporal distributions** to model seasonal variation in solar output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17bd6fac", + "metadata": {}, + "outputs": [], + "source": [ + "# Define seasonal solar production profile (12 months)\n", + "# Higher output in summer months (normalized to sum to 1)\n", + "monthly_solar_factors = np.array([\n", + " 0.04, # Jan - Low winter output\n", + " 0.05, # Feb\n", + " 0.08, # Mar - Spring increase\n", + " 0.10, # Apr\n", + " 0.12, # May\n", + " 0.13, # Jun - Peak summer\n", + " 0.13, # Jul - Peak summer\n", + " 0.12, # Aug\n", + " 0.09, # Sep - Autumn decline\n", + " 0.07, # Oct\n", + " 0.04, # Nov\n", + " 0.03, # Dec - Low winter output\n", + "])\n", + "\n", + "# Verify normalization\n", + "print(f\"Sum of monthly factors: {monthly_solar_factors.sum():.2f}\")\n", + "\n", + "# Create monthly temporal distribution for solar production\n", + "solar_production_td = TemporalDistribution(\n", + " date=np.array(range(12), dtype=\"timedelta64[M]\"),\n", + " amount=monthly_solar_factors\n", + ")\n", + "\n", + "# Constant monthly production for conventional (1/12 each month)\n", + "constant_monthly_td = TemporalDistribution(\n", + " date=np.array(range(12), dtype=\"timedelta64[M]\"),\n", + " amount=np.array([1/12] * 12)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d9e1234", + "metadata": {}, + "outputs": [], + "source": [ + "# FOREGROUND DATABASE\n", + "foreground_data = {\n", + " # Product node\n", + " (\"foreground\", \"electricity\"): {\n", + " \"name\": \"Electricity (kWh)\",\n", + " \"type\": bd.labels.product_node_default,\n", + " \"unit\": \"kWh\",\n", + " },\n", + " \n", + " # Solar PV process - seasonal output pattern\n", + " (\"foreground\", \"solar_pv\"): {\n", + " \"name\": \"Solar PV generation\",\n", + " \"location\": \"EU\",\n", + " \"type\": bd.labels.process_node_default,\n", + " \"operation_time_limits\": (0, 11), # 12 months operation\n", + " \"exchanges\": [\n", + " {\n", + " \"amount\": 1200, # 1200 kWh/year total production (100 kWh/month average)\n", + " \"type\": bd.labels.production_edge_default,\n", + " \"input\": (\"foreground\", \"electricity\"),\n", + " \"temporal_distribution\": solar_production_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 10, # 10 kg CO2 for panel manufacturing (construction phase)\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"CO2\"),\n", + " \"temporal_distribution\": TemporalDistribution(\n", + " date=np.array([0], dtype=\"timedelta64[M]\"),\n", + " amount=np.array([1])\n", + " ),\n", + " },\n", + " {\n", + " \"amount\": 50, # 50 L water for panel cleaning over year\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"water\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " ],\n", + " },\n", + " \n", + " # Conventional gas backup - constant output\n", + " (\"foreground\", \"gas_backup\"): {\n", + " \"name\": \"Gas backup generation\",\n", + " \"location\": \"EU\",\n", + " \"type\": bd.labels.process_node_default,\n", + " \"operation_time_limits\": (0, 11), # 12 months operation\n", + " \"exchanges\": [\n", + " {\n", + " \"amount\": 1200, # 1200 kWh/year (100 kWh/month)\n", + " \"type\": bd.labels.production_edge_default,\n", + " \"input\": (\"foreground\", \"electricity\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 600, # 600 kg CO2/year (50 kg/month) - higher carbon intensity\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"CO2\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 120, # 120 L water/year for cooling\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"water\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " ],\n", + " },\n", + "}\n", + "\n", + "bd.Database(\"foreground\").write(foreground_data)" + ] + }, + { + "cell_type": "markdown", + "id": "7b3d4512", + "metadata": {}, + "source": [ + "### LCIA Methods\n", + "\n", + "We define two impact categories:\n", + "1. **Climate change** - Simple CO2 characterization\n", + "2. **Water scarcity** - With seasonal variation (higher impact in dry summer months)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5e67890", + "metadata": {}, + "outputs": [], + "source": [ + "# Climate change method (constant characterization factor)\n", + "bd.Method((\"GWP\", \"monthly_example\")).write(\n", + " [\n", + " ((\"biosphere3\", \"CO2\"), 1), # 1 kg CO2eq per kg CO2\n", + " ]\n", + ")\n", + "\n", + "# Water scarcity method (using static characterization for now)\n", + "# In practice, this could vary by season - higher scarcity in summer\n", + "bd.Method((\"water_scarcity\", \"monthly_example\")).write(\n", + " [\n", + " ((\"biosphere3\", \"water\"), 1), # 1 L water eq per L water\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "## 2. Configure LCA with Monthly Resolution\n", + "\n", + "The key difference from yearly resolution is setting `temporal_resolution: \"month\"` in the configuration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6g7h8", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex import lca_processor\n", + "\n", + "# Define monthly demand for 24 months (2 years)\n", + "# Constant 100 kWh/month demand\n", + "n_months = 24\n", + "dates = [datetime(2024 + m // 12, (m % 12) + 1, 1).isoformat() for m in range(n_months)]\n", + "\n", + "td_demand = TemporalDistribution(\n", + " date=np.array(dates, dtype=\"datetime64[s]\"),\n", + " amount=np.array([100] * n_months), # 100 kWh per month\n", + ")\n", + "\n", + "# Get product node for demand specification\n", + "electricity_product = bd.get_node(database=\"foreground\", code=\"electricity\")\n", + "\n", + "# Configure LCA with MONTHLY resolution\n", + "lca_config = lca_processor.LCAConfig(\n", + " demand={electricity_product: td_demand},\n", + " temporal={\n", + " \"start_date\": datetime(2024, 1, 1),\n", + " \"temporal_resolution\": \"month\", # <-- KEY: Monthly resolution\n", + " \"time_horizon\": 100,\n", + " },\n", + " characterization_methods=[\n", + " {\n", + " \"category_name\": \"climate_change\",\n", + " \"brightway_method\": (\"GWP\", \"monthly_example\"),\n", + " },\n", + " {\n", + " \"category_name\": \"water_scarcity\",\n", + " \"brightway_method\": (\"water_scarcity\", \"monthly_example\"),\n", + " },\n", + " ],\n", + " background_inventory={\n", + " \"cutoff\": 1e4,\n", + " \"calculation_method\": \"sequential\",\n", + " },\n", + ")\n", + "\n", + "print(f\"Temporal resolution: {lca_config.temporal.temporal_resolution}\")" + ] + }, + { + "cell_type": "markdown", + "id": "i9j0k1l2", + "metadata": {}, + "source": [ + "## 3. Process LCA Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m3n4o5p6", + "metadata": {}, + "outputs": [], + "source": [ + "# Create LCA data processor\n", + "lca_processor_instance = lca_processor.LCADataProcessor(lca_config)\n", + "\n", + "# Inspect the results\n", + "print(f\"System time range: {min(lca_processor_instance.system_time)} to {max(lca_processor_instance.system_time)}\")\n", + "print(f\"Number of system time points: {len(lca_processor_instance.system_time)}\")\n", + "print(f\"Process time indices: {sorted(lca_processor_instance.process_time)}\")\n", + "print(f\"Processes: {list(lca_processor_instance.processes.values())}\")\n", + "print(f\"Products: {list(lca_processor_instance.products.values())}\")" + ] + }, + { + "cell_type": "markdown", + "id": "q7r8s9t0", + "metadata": {}, + "source": [ + "## 4. Examine Monthly Production Patterns\n", + "\n", + "Let's visualize how the solar PV production varies across months." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u1v2w3x4", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "# Extract solar PV production by month\n", + "production = lca_processor_instance.foreground_production\n", + "solar_production = {k[2]: v for k, v in production.items() if k[0] == 'solar_pv'}\n", + "gas_production = {k[2]: v for k, v in production.items() if k[0] == 'gas_backup'}\n", + "\n", + "months = list(range(12))\n", + "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', \n", + " 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n", + "\n", + "solar_values = [solar_production.get(m, 0) for m in months]\n", + "gas_values = [gas_production.get(m, 0) for m in months]\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "x = np.arange(12)\n", + "width = 0.35\n", + "\n", + "bars1 = ax.bar(x - width/2, solar_values, width, label='Solar PV', color='gold')\n", + "bars2 = ax.bar(x + width/2, gas_values, width, label='Gas Backup', color='gray')\n", + "\n", + "ax.set_xlabel('Month')\n", + "ax.set_ylabel('Production per process unit (kWh)')\n", + "ax.set_title('Monthly Production Pattern by Technology')\n", + "ax.set_xticks(x)\n", + "ax.set_xticklabels(month_names)\n", + "ax.legend()\n", + "ax.grid(axis='y', alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f\"\\nTotal annual production:\")\n", + "print(f\" Solar PV: {sum(solar_values):.1f} kWh\")\n", + "print(f\" Gas backup: {sum(gas_values):.1f} kWh\")" + ] + }, + { + "cell_type": "markdown", + "id": "y5z6a7b8", + "metadata": {}, + "source": [ + "## 5. Convert to Optimization Model Inputs\n", + "\n", + "Now we can convert the LCA data to optimization model inputs and run the optimizer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d0e1f2", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.converter import ModelInputManager\n", + "\n", + "# Convert LCA outputs to optimization inputs\n", + "manager = ModelInputManager(lca_processor_instance)\n", + "model_inputs = manager.get_model_inputs()\n", + "\n", + "print(f\"SYSTEM_TIME indices: {len(model_inputs.SYSTEM_TIME)} time points\")\n", + "print(f\"PROCESS_TIME indices: {model_inputs.PROCESS_TIME}\")\n", + "print(f\"PROCESS set: {model_inputs.PROCESS}\")\n", + "print(f\"PRODUCT set: {model_inputs.PRODUCT}\")" + ] + }, + { + "cell_type": "markdown", + "id": "g3h4i5j6", + "metadata": {}, + "source": [ + "## 6. Create and Solve Optimization Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k7l8m9n0", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.optimizer import create_model, solve_model\n", + "\n", + "# Create Pyomo model minimizing climate change impact\n", + "model = create_model(\n", + " inputs=model_inputs,\n", + " name=\"monthly_electricity_optimization\",\n", + " objective_category=\"climate_change\",\n", + ")\n", + "\n", + "# Solve the model\n", + "results = solve_model(model, solver=\"glpk\")\n", + "print(f\"Solver status: {results.solver.status}\")\n", + "print(f\"Solver termination: {results.solver.termination_condition}\")" + ] + }, + { + "cell_type": "markdown", + "id": "o1p2q3r4", + "metadata": {}, + "source": [ + "## 7. Analyze Results\n", + "\n", + "Let's see how the optimizer schedules installations and operations across months." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s5t6u7v8", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.postprocessing import PostProcessor\n", + "\n", + "# Create post-processor\n", + "pp = PostProcessor(model)\n", + "\n", + "# Get installation decisions\n", + "installations = pp.get_installations()\n", + "print(\"\\nInstallation decisions:\")\n", + "print(installations)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w9x0y1z2", + "metadata": {}, + "outputs": [], + "source": [ + "# Get operation levels over time\n", + "operations = pp.get_operation()\n", + "print(\"\\nOperation levels (first 12 months):\")\n", + "print(operations.head(12))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3b4c5d6", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize operation over time\n", + "fig, ax = plt.subplots(figsize=(12, 5))\n", + "\n", + "for process in model_inputs.PROCESS:\n", + " process_ops = operations[operations['process'] == process]\n", + " if not process_ops.empty:\n", + " time_indices = process_ops['system_time'].values\n", + " operation_values = process_ops['operation'].values\n", + " ax.plot(time_indices, operation_values, label=process, marker='o', markersize=4)\n", + "\n", + "ax.set_xlabel('System Time (monthly index)')\n", + "ax.set_ylabel('Operation Level')\n", + "ax.set_title('Operation Scheduling Over 24 Months')\n", + "ax.legend()\n", + "ax.grid(alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "e7f8g9h0", + "metadata": {}, + "source": [ + "## 8. Summary\n", + "\n", + "This notebook demonstrated how to use `optimex` with **monthly temporal resolution**:\n", + "\n", + "1. **Temporal distributions** use `timedelta64[M]` for monthly offsets\n", + "2. **LCA configuration** sets `temporal_resolution: \"month\"`\n", + "3. **Seasonal patterns** can be modeled by varying amounts across months\n", + "4. The optimizer accounts for seasonal supply variations when meeting demand\n", + "\n", + "This enables more realistic modeling of:\n", + "- Renewable energy seasonality\n", + "- Agricultural cycles\n", + "- Seasonal water availability\n", + "- Monthly demand patterns" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_temporal_resolution.py b/tests/test_temporal_resolution.py new file mode 100644 index 0000000..07cf8aa --- /dev/null +++ b/tests/test_temporal_resolution.py @@ -0,0 +1,304 @@ +""" +Tests for flexible temporal resolution support in optimex. +""" + +from datetime import datetime + +import bw2data as bd +import numpy as np +import pytest +from bw2data.tests import bw2test +from bw_temporalis import TemporalDistribution + +from optimex import lca_processor +from optimex.lca_processor import TemporalResolutionEnum + + +class TestTemporalResolutionEnum: + """Tests for the TemporalResolutionEnum class properties.""" + + def test_year_resolution_properties(self): + """Test that year resolution returns correct numpy and pandas mappings.""" + res = TemporalResolutionEnum.year + assert res.numpy_unit == "Y" + assert res.pandas_freq == "YE" + assert res.pandas_offset == "year" + + def test_month_resolution_properties(self): + """Test that month resolution returns correct numpy and pandas mappings.""" + res = TemporalResolutionEnum.month + assert res.numpy_unit == "M" + assert res.pandas_freq == "ME" + assert res.pandas_offset == "month" + + def test_day_resolution_properties(self): + """Test that day resolution returns correct numpy and pandas mappings.""" + res = TemporalResolutionEnum.day + assert res.numpy_unit == "D" + assert res.pandas_freq == "D" + assert res.pandas_offset == "day" + + +@pytest.fixture(scope="module") +@bw2test +def setup_monthly_resolution_databases(): + """Set up Brightway databases for monthly resolution testing.""" + bd.projects.set_current("__test_monthly_resolution_db__") + + bio_db = bd.Database("biosphere3") + bio_db.write( + { + ("biosphere3", "CO2"): { + "type": "emission", + "name": "carbon dioxide", + }, + }, + ) + bio_db.register() + + # Simple background database + background = bd.Database("db_2020") + background.write( + { + ("db_2020", "I1"): { + "name": "node I1", + "location": "somewhere", + "reference product": "I1", + "exchanges": [ + { + "amount": 1, + "type": "production", + "input": ("db_2020", "I1"), + }, + { + "amount": 1, + "type": "biosphere", + "input": ("biosphere3", "CO2"), + }, + ], + }, + } + ) + background.metadata["representative_time"] = datetime(2020, 1, 1).isoformat() + background.register() + + # Foreground database with monthly temporal distributions + foreground = bd.Database("foreground") + foreground.write( + { + # Product node + ("foreground", "R1"): { + "name": "Product R1", + "type": bd.labels.product_node_default, + "unit": "kg", + }, + # Process node with monthly temporal distribution + ("foreground", "P1"): { + "name": "process P1", + "location": "somewhere", + "type": bd.labels.process_node_default, + "operation_time_limits": (0, 11), # 12 months of operation (0-11) + "exchanges": [ + { + "amount": 1, + "type": bd.labels.production_edge_default, + "input": ("foreground", "R1"), + # Production spread over 12 months + "temporal_distribution": TemporalDistribution( + date=np.array(range(12), dtype="timedelta64[M]"), + amount=np.array([1/12] * 12), + ), + "operation": True, + }, + { + "amount": 12, # 1 kg/month * 12 months + "type": bd.labels.consumption_edge_default, + "input": ("db_2020", "I1"), + # Construction at month 0 + "temporal_distribution": TemporalDistribution( + date=np.array([0], dtype="timedelta64[M]"), + amount=np.array([1]), + ), + }, + { + "amount": 12, # 1 kg CO2/month * 12 months + "type": bd.labels.biosphere_edge_default, + "input": ("biosphere3", "CO2"), + # Emissions spread over 12 months + "temporal_distribution": TemporalDistribution( + date=np.array(range(12), dtype="timedelta64[M]"), + amount=np.array([1/12] * 12), + ), + "operation": True, + }, + ], + }, + } + ) + foreground.register() + + bd.Method(("GWP", "example")).write( + [ + (("biosphere3", "CO2"), 1), + ] + ) + + +@pytest.fixture(scope="module") +def mock_monthly_lca_data_processor(setup_monthly_resolution_databases): + """Create an LCADataProcessor configured for monthly resolution.""" + # Create demand for 12 months starting from January 2020 + months = range(12) + dates = [datetime(2020, m + 1, 1).isoformat() for m in months] + td_demand = TemporalDistribution( + date=np.array(dates, dtype="datetime64[s]"), + amount=np.array([1] * 12), # 1 unit demand per month + ) + + product_r1 = bd.get_node(database="foreground", code="R1") + + lca_config = lca_processor.LCAConfig( + demand={product_r1: td_demand}, + temporal={ + "start_date": datetime(2020, 1, 1), + "temporal_resolution": "month", # Use monthly resolution + "time_horizon": 100, # 100 years for characterization + }, + characterization_methods=[ + { + "category_name": "climate_change", + "brightway_method": ("GWP", "example"), + }, + ], + background_inventory={ + "cutoff": 1e4, + "calculation_method": "sequential", + }, + ) + + return lca_processor.LCADataProcessor(lca_config) + + +class TestMonthlyResolution: + """Tests for monthly temporal resolution.""" + + def test_processor_initialization_with_monthly_resolution( + self, mock_monthly_lca_data_processor + ): + """Test that LCADataProcessor initializes correctly with monthly resolution.""" + processor = mock_monthly_lca_data_processor + assert isinstance(processor, lca_processor.LCADataProcessor) + assert processor.config.temporal.temporal_resolution == TemporalResolutionEnum.month + + def test_system_time_is_monthly_indexed(self, mock_monthly_lca_data_processor): + """Test that system time uses monthly indices.""" + processor = mock_monthly_lca_data_processor + system_time = processor.system_time + + # For monthly resolution starting Jan 2020, indices should be month-based + # Jan 2020 = 2020*12 + 0 = 24240 + expected_start = 2020 * 12 # 24240 + + assert min(system_time) == expected_start + # Should have at least 12 months + assert len(system_time) >= 12 + + def test_process_time_is_monthly_indexed(self, mock_monthly_lca_data_processor): + """Test that process time uses monthly indices (0-11 for 12 months).""" + processor = mock_monthly_lca_data_processor + process_time = processor.process_time + + # Process time should include months 0-11 + assert 0 in process_time + assert 11 in process_time + + def test_demand_keys_use_monthly_indices(self, mock_monthly_lca_data_processor): + """Test that demand dictionary keys use monthly indices.""" + processor = mock_monthly_lca_data_processor + demand = processor.demand + + # All keys should be (product_code, monthly_index) + expected_start_month = 2020 * 12 + for (product_code, time_idx), amount in demand.items(): + assert time_idx >= expected_start_month + assert time_idx < expected_start_month + 12 + assert product_code == "R1" + assert amount == 1 # 1 unit per month + + def test_foreground_tensors_use_monthly_process_time( + self, mock_monthly_lca_data_processor + ): + """Test that foreground tensors use monthly process time indices.""" + processor = mock_monthly_lca_data_processor + + # Check production tensor + production = processor.foreground_production + time_indices = {k[2] for k in production.keys()} + assert 0 in time_indices # Should have month 0 + assert 11 in time_indices # Should have month 11 + + # Check biosphere tensor + biosphere = processor.foreground_biosphere + bio_time_indices = {k[2] for k in biosphere.keys()} + assert 0 in bio_time_indices + assert 11 in bio_time_indices + + def test_characterization_keys_use_monthly_system_time( + self, mock_monthly_lca_data_processor + ): + """Test that characterization tensor keys use monthly system time indices.""" + processor = mock_monthly_lca_data_processor + characterization = processor.characterization + + # Keys should be (category, flow_code, monthly_system_time_index) + expected_start_month = 2020 * 12 + for (category, flow_code, time_idx), value in characterization.items(): + assert time_idx >= expected_start_month + assert category == "climate_change" + + +class TestDailyResolution: + """Basic tests for daily temporal resolution enum properties.""" + + def test_daily_resolution_enum_exists(self): + """Test that daily resolution is available in the enum.""" + assert TemporalResolutionEnum.day == "day" + + def test_daily_resolution_numpy_unit(self): + """Test that daily resolution returns correct numpy unit.""" + assert TemporalResolutionEnum.day.numpy_unit == "D" + + +class TestResolutionBackwardCompatibility: + """Tests to ensure backward compatibility with yearly resolution.""" + + def test_year_is_default_resolution(self): + """Test that yearly resolution is the default.""" + config = lca_processor.TemporalConfig( + start_date=datetime(2020, 1, 1) + ) + assert config.temporal_resolution == TemporalResolutionEnum.year + + def test_year_string_accepted(self): + """Test that 'year' string is accepted as resolution.""" + config = lca_processor.TemporalConfig( + start_date=datetime(2020, 1, 1), + temporal_resolution="year" + ) + assert config.temporal_resolution == TemporalResolutionEnum.year + + def test_month_string_accepted(self): + """Test that 'month' string is accepted as resolution.""" + config = lca_processor.TemporalConfig( + start_date=datetime(2020, 1, 1), + temporal_resolution="month" + ) + assert config.temporal_resolution == TemporalResolutionEnum.month + + def test_day_string_accepted(self): + """Test that 'day' string is accepted as resolution.""" + config = lca_processor.TemporalConfig( + start_date=datetime(2020, 1, 1), + temporal_resolution="day" + ) + assert config.temporal_resolution == TemporalResolutionEnum.day From 1782126d44908d04aea5ffa3a9aabc98444f73f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:57:44 +0000 Subject: [PATCH 4/9] Address code review comments: improve documentation and fix precision issues Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- optimex/lca_processor.py | 32 +++++++++++++++++++++---------- tests/test_temporal_resolution.py | 8 ++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 5d950e0..310eb47 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -384,6 +384,13 @@ def _parse_demand(self) -> None: - self._demand: dict with keys (product_code, time_index) and values as amounts. - self._products: dict mapping product codes to product names. - self._system_time: range of time indices covering the longest demand interval. + + Notes + ----- + Time indexing schemes vary by resolution: + - year: Uses calendar year (e.g., 2020, 2021, ...) + - month: Uses months since year 0 (e.g., 2020*12 + 0 = 24240 for Jan 2020) + - day: Uses days since Unix epoch (1970-01-01), consistent with numpy datetime64[D] """ raw_demand = self.config.demand resolution = self.config.temporal.temporal_resolution @@ -391,11 +398,12 @@ def _parse_demand(self) -> None: longest_demand_interval = 0 # Compute the start index based on resolution + # Year: calendar year, Month: months since year 0, Day: days since Unix epoch if resolution == TemporalResolutionEnum.year: start_index = start_date.year elif resolution == TemporalResolutionEnum.month: start_index = start_date.year * 12 + start_date.month - 1 - else: # day + else: # day - uses days since Unix epoch (1970-01-01) start_index = int(np.datetime64(start_date, 'D').astype(int)) for product_node, td in raw_demand.items(): @@ -418,10 +426,10 @@ def _parse_demand(self) -> None: if resolution == TemporalResolutionEnum.year: time_indices = td.date.astype(f"datetime64[{numpy_unit}]").astype(int) + 1970 elif resolution == TemporalResolutionEnum.month: - # Convert to months since epoch, then add offset to get absolute month index + # Convert to months since epoch, then add offset to get months since year 0 dates_as_months = td.date.astype("datetime64[M]").astype(int) - time_indices = dates_as_months + 1970 * 12 # months since year 0 - else: # day + time_indices = dates_as_months + 1970 * 12 + else: # day - uses days since Unix epoch, consistent with numpy datetime64[D] time_indices = td.date.astype("datetime64[D]").astype(int) if time_indices[-1] - start_index > longest_demand_interval: @@ -953,22 +961,26 @@ def _construct_characterization_tensor(self) -> None: for time_idx in system_time_list: # Compute cutoff based on resolution + # The cutoff determines how many years of radiative forcing to sum if resolution == TemporalResolutionEnum.year: cutoff = start_date.year + time_horizon - time_idx - 1 elif resolution == TemporalResolutionEnum.month: - # Convert time_idx to year for cutoff calculation + # Convert time_idx to elapsed years for cutoff calculation start_month_idx = start_date.year * 12 + start_date.month - 1 months_elapsed = time_idx - start_month_idx - years_elapsed = months_elapsed / 12.0 - cutoff = int(time_horizon - years_elapsed - 1) + # Use integer division to avoid floating-point precision issues + years_elapsed = months_elapsed // 12 + cutoff = time_horizon - years_elapsed - 1 else: # day + # Note: 365.25 is used as an approximation for average days per year + # This may accumulate small errors over very long time horizons start_day_idx = int(np.datetime64(start_date, 'D').astype(int)) days_elapsed = time_idx - start_day_idx - years_elapsed = days_elapsed / 365.25 - cutoff = int(time_horizon - years_elapsed - 1) + years_elapsed = days_elapsed // 365 # Use integer division + cutoff = time_horizon - years_elapsed - 1 cutoff = max(0, cutoff) # Ensure non-negative - cumulative_rf = rf_series[:cutoff].sum() if cutoff > 0 else 0.0 + cumulative_rf = rf_series[:cutoff].sum() characterization_tensor[(category_name, flow_code, time_idx)] = ( cumulative_rf ) diff --git a/tests/test_temporal_resolution.py b/tests/test_temporal_resolution.py index 07cf8aa..9a7f48f 100644 --- a/tests/test_temporal_resolution.py +++ b/tests/test_temporal_resolution.py @@ -97,7 +97,7 @@ def setup_monthly_resolution_databases(): "name": "process P1", "location": "somewhere", "type": bd.labels.process_node_default, - "operation_time_limits": (0, 11), # 12 months of operation (0-11) + "operation_time_limits": (0, 11), # 12 months: indices 0-11 inclusive "exchanges": [ { "amount": 1, @@ -195,9 +195,9 @@ def test_system_time_is_monthly_indexed(self, mock_monthly_lca_data_processor): processor = mock_monthly_lca_data_processor system_time = processor.system_time - # For monthly resolution starting Jan 2020, indices should be month-based - # Jan 2020 = 2020*12 + 0 = 24240 - expected_start = 2020 * 12 # 24240 + # For monthly resolution starting Jan 2020, index = year*12 + (month-1) + # Jan 2020: 2020 * 12 + 0 = 24240 (months since year 0) + expected_start = 2020 * 12 assert min(system_time) == expected_start # Should have at least 12 months From b32d0b43ad30fd131067d2947cf4d31b00a9ef2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:30:25 +0000 Subject: [PATCH 5/9] Add mixed temporal resolution support with automatic detection and conversion Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- optimex/lca_processor.py | 242 +++++++++++++++++++++++++++++- tests/test_temporal_resolution.py | 143 ++++++++++++++++++ 2 files changed, 378 insertions(+), 7 deletions(-) diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 310eb47..072cacb 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -83,6 +83,132 @@ def pandas_offset(self) -> str: } return mapping[self.value] + @property + def priority(self) -> int: + """Return priority for resolution comparison (higher = finer granularity).""" + mapping = { + "year": 1, + "month": 2, + "day": 3, + } + return mapping[self.value] + + @staticmethod + def from_numpy_unit(unit: str) -> "TemporalResolutionEnum": + """Convert numpy timedelta64 unit code to TemporalResolutionEnum.""" + mapping = { + "Y": TemporalResolutionEnum.year, + "M": TemporalResolutionEnum.month, + "D": TemporalResolutionEnum.day, + } + if unit not in mapping: + raise ValueError(f"Unsupported numpy unit: {unit}. Supported: Y, M, D") + return mapping[unit] + + @staticmethod + def get_finest(*resolutions: "TemporalResolutionEnum") -> "TemporalResolutionEnum": + """Return the finest (most granular) resolution from a list of resolutions.""" + return max(resolutions, key=lambda r: r.priority) + + def convert_to(self, value: int, target: "TemporalResolutionEnum") -> int: + """ + Convert a time index from this resolution to target resolution. + + Note: Conversion from coarser to finer resolution maps to the start of the period. + For example, year 1 -> month 12 (start of year 1 in months). + """ + if self == target: + return value + + # Convert to days as intermediate representation, then to target + if self == TemporalResolutionEnum.year: + days = value * 365 # Approximate + months = value * 12 + elif self == TemporalResolutionEnum.month: + days = value * 30 # Approximate + months = value + else: # day + days = value + months = value // 30 # Approximate + + if target == TemporalResolutionEnum.day: + return days + elif target == TemporalResolutionEnum.month: + return months + else: # year + return days // 365 if self == TemporalResolutionEnum.day else value // 12 + + +def detect_temporal_resolution(td: TemporalDistribution) -> TemporalResolutionEnum: + """ + Detect the temporal resolution of a TemporalDistribution based on its date values. + + Since bw_temporalis converts all timedelta64 to seconds internally, we infer + the resolution from the value patterns: + - 1 year ≈ 31,556,952 seconds + - 1 month ≈ 2,629,746 seconds + - 1 day = 86,400 seconds + + Parameters + ---------- + td : TemporalDistribution + The temporal distribution to analyze. + + Returns + ------- + TemporalResolutionEnum + The detected resolution (year, month, or day). + """ + if td.date.size == 0: + return TemporalResolutionEnum.year # Default + + # Constants for time conversions (in seconds) + SECONDS_PER_DAY = 86400 + SECONDS_PER_MONTH = 2629746 # ~30.44 days + SECONDS_PER_YEAR = 31556952 # 365.2425 days + + # Get the values in seconds + values = td.date.astype('timedelta64[s]').astype(int) + + # If only one value (possibly 0), check the dtype string as fallback + dtype_str = str(td.date.dtype) + if "[Y]" in dtype_str: + return TemporalResolutionEnum.year + elif "[M]" in dtype_str: + return TemporalResolutionEnum.month + elif "[D]" in dtype_str: + return TemporalResolutionEnum.day + + # Get non-zero values to analyze the step size + non_zero = values[values != 0] + if len(non_zero) == 0: + # All zeros or single zero, try to infer from dtype or default + return TemporalResolutionEnum.year + + # Get the minimum non-zero value or the GCD of differences + if len(values) > 1: + # Calculate differences between consecutive values + diffs = np.diff(values) + non_zero_diffs = diffs[diffs != 0] + if len(non_zero_diffs) > 0: + step = np.min(np.abs(non_zero_diffs)) + else: + step = np.min(np.abs(non_zero)) + else: + step = np.min(np.abs(non_zero)) + + # Determine resolution based on step size + # Allow 10% tolerance for rounding + if step >= SECONDS_PER_YEAR * 0.9: + return TemporalResolutionEnum.year + elif step >= SECONDS_PER_MONTH * 0.9: + return TemporalResolutionEnum.month + elif step >= SECONDS_PER_DAY * 0.9: + return TemporalResolutionEnum.day + else: + # Sub-day resolution not supported, default to day + return TemporalResolutionEnum.day + class CharacterizationMethodConfig(BaseModel): """ @@ -370,6 +496,88 @@ def internal_demand_technosphere(self) -> dict: """Read-only access to the internal demand technosphere tensor.""" return self._internal_demand_technosphere + def _convert_temporal_resolution( + self, + time_indices: np.ndarray, + amounts: np.ndarray, + source_resolution: TemporalResolutionEnum, + target_resolution: TemporalResolutionEnum, + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Convert time indices and amounts from one temporal resolution to another. + + When converting from coarser to finer resolution (e.g., year to month), + each time point is expanded. The amount is distributed uniformly across + the expanded time points. + + When converting from finer to coarser resolution (e.g., month to year), + time points are aggregated. This scenario is less common but supported. + + Parameters + ---------- + time_indices : np.ndarray + Array of time indices in source resolution. + amounts : np.ndarray + Array of amounts corresponding to each time index. + source_resolution : TemporalResolutionEnum + The resolution of the input time indices. + target_resolution : TemporalResolutionEnum + The desired output resolution. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + Converted time indices and amounts in target resolution. + """ + if source_resolution == target_resolution: + return time_indices, amounts + + # Define conversion factors + # year -> month: multiply by 12 + # month -> day: multiply by 30 (approximate) + # year -> day: multiply by 365 (approximate) + conversion_factors = { + (TemporalResolutionEnum.year, TemporalResolutionEnum.month): 12, + (TemporalResolutionEnum.year, TemporalResolutionEnum.day): 365, + (TemporalResolutionEnum.month, TemporalResolutionEnum.day): 30, + (TemporalResolutionEnum.month, TemporalResolutionEnum.year): 1/12, + (TemporalResolutionEnum.day, TemporalResolutionEnum.month): 1/30, + (TemporalResolutionEnum.day, TemporalResolutionEnum.year): 1/365, + } + + factor = conversion_factors.get((source_resolution, target_resolution)) + if factor is None: + raise ValueError( + f"Unsupported resolution conversion: {source_resolution} -> {target_resolution}" + ) + + if factor >= 1: + # Expanding: coarser to finer resolution + factor = int(factor) + new_time_indices = [] + new_amounts = [] + + for idx, amount in zip(time_indices, amounts): + # Create expanded time points + base_idx = int(idx * factor) + expanded_indices = np.arange(base_idx, base_idx + factor) + # Distribute amount uniformly across expanded time points + expanded_amounts = np.full(factor, amount / factor) + new_time_indices.extend(expanded_indices) + new_amounts.extend(expanded_amounts) + + return np.array(new_time_indices), np.array(new_amounts) + else: + # Aggregating: finer to coarser resolution (less common) + inverse_factor = int(1 / factor) + aggregated = {} + + for idx, amount in zip(time_indices, amounts): + target_idx = int(idx // inverse_factor) + aggregated[target_idx] = aggregated.get(target_idx, 0) + amount + + return np.array(list(aggregated.keys())), np.array(list(aggregated.values())) + def _parse_demand(self) -> None: """ Parse and process the demand dictionary from the configuration. @@ -460,6 +668,11 @@ def _construct_foreground_tensors(self) -> None: three types of edges: production edges (to product nodes), consumption edges (from background or foreground products), and biosphere edges (emissions). + Mixed temporal resolutions are supported: exchanges can have different temporal + resolutions (year, month, day), and the system will convert them to the configured + resolution. When an exchange uses a coarser resolution than configured, its values + are expanded to the finer resolution. + Side Effects ----------- Updates the following instance attributes: @@ -486,8 +699,8 @@ def _construct_foreground_tensors(self) -> None: production_tensor = {} biosphere_tensor = {} - resolution = self.config.temporal.temporal_resolution - numpy_unit = resolution.numpy_unit + target_resolution = self.config.temporal.temporal_resolution + target_numpy_unit = target_resolution.numpy_unit for act in self.foreground_db: # Only process nodes (not product nodes) @@ -504,16 +717,31 @@ def _construct_foreground_tensors(self) -> None: temporal_dist = exc.get( "temporal_distribution", TemporalDistribution( - date=np.array([0], dtype=f"timedelta64[{numpy_unit}]"), amount=np.array([1]) + date=np.array([0], dtype=f"timedelta64[{target_numpy_unit}]"), amount=np.array([1]) ), - ) - # Convert timedelta to process time indices based on resolution - time_indices = temporal_dist.date.astype(f"timedelta64[{numpy_unit}]").astype(int) + ) + + # Detect the resolution of this temporal distribution + source_resolution = detect_temporal_resolution(temporal_dist) + source_numpy_unit = source_resolution.numpy_unit + + # Convert timedelta to process time indices + # First convert to source resolution, then to target if needed + raw_time_indices = temporal_dist.date.astype(f"timedelta64[{source_numpy_unit}]").astype(int) + temporal_factor = temporal_dist.amount + + # Convert to target resolution if different + if source_resolution != target_resolution: + time_indices, temporal_factor = self._convert_temporal_resolution( + raw_time_indices, temporal_factor, source_resolution, target_resolution + ) + else: + time_indices = raw_time_indices + # Ensure all time indices are included in process time self._process_time.update( idx for idx in time_indices if idx not in self._process_time ) - temporal_factor = temporal_dist.amount # Skip if temporal distribution is missing or invalid (empty arrays) if time_indices.size == 0 or temporal_factor.size == 0: diff --git a/tests/test_temporal_resolution.py b/tests/test_temporal_resolution.py index 9a7f48f..e09314d 100644 --- a/tests/test_temporal_resolution.py +++ b/tests/test_temporal_resolution.py @@ -302,3 +302,146 @@ def test_day_string_accepted(self): temporal_resolution="day" ) assert config.temporal_resolution == TemporalResolutionEnum.day + + +class TestMixedResolutions: + """Tests for mixing different temporal resolutions across processes.""" + + def test_resolution_priority(self): + """Test that resolution priority is correctly ordered (day > month > year).""" + assert TemporalResolutionEnum.day.priority > TemporalResolutionEnum.month.priority + assert TemporalResolutionEnum.month.priority > TemporalResolutionEnum.year.priority + + def test_get_finest_resolution(self): + """Test that get_finest returns the most granular resolution.""" + result = TemporalResolutionEnum.get_finest( + TemporalResolutionEnum.year, + TemporalResolutionEnum.month, + TemporalResolutionEnum.day + ) + assert result == TemporalResolutionEnum.day + + result = TemporalResolutionEnum.get_finest( + TemporalResolutionEnum.year, + TemporalResolutionEnum.month + ) + assert result == TemporalResolutionEnum.month + + def test_detect_temporal_resolution_year(self): + """Test detection of yearly temporal distribution.""" + from optimex.lca_processor import detect_temporal_resolution + td = TemporalDistribution( + date=np.array([0, 1, 2], dtype="timedelta64[Y]"), + amount=np.array([0.33, 0.33, 0.34]) + ) + assert detect_temporal_resolution(td) == TemporalResolutionEnum.year + + def test_detect_temporal_resolution_month(self): + """Test detection of monthly temporal distribution.""" + from optimex.lca_processor import detect_temporal_resolution + td = TemporalDistribution( + date=np.array([0, 1, 2, 3, 4, 5], dtype="timedelta64[M]"), + amount=np.array([0.16, 0.17, 0.16, 0.17, 0.17, 0.17]) + ) + assert detect_temporal_resolution(td) == TemporalResolutionEnum.month + + def test_detect_temporal_resolution_day(self): + """Test detection of daily temporal distribution.""" + from optimex.lca_processor import detect_temporal_resolution + td = TemporalDistribution( + date=np.array([0, 1, 2, 3, 4], dtype="timedelta64[D]"), + amount=np.array([0.2, 0.2, 0.2, 0.2, 0.2]) + ) + assert detect_temporal_resolution(td) == TemporalResolutionEnum.day + + def test_from_numpy_unit(self): + """Test conversion from numpy unit codes to enum.""" + assert TemporalResolutionEnum.from_numpy_unit("Y") == TemporalResolutionEnum.year + assert TemporalResolutionEnum.from_numpy_unit("M") == TemporalResolutionEnum.month + assert TemporalResolutionEnum.from_numpy_unit("D") == TemporalResolutionEnum.day + + +class TestResolutionConversion: + """Tests for temporal resolution conversion logic.""" + + def test_year_to_month_conversion(self): + """Test converting yearly indices to monthly.""" + from optimex.lca_processor import LCADataProcessor, LCAConfig, TemporalResolutionEnum + + # Create a minimal mock to test the conversion method + # We'll use the static method approach + time_indices = np.array([0, 1, 2]) # Years 0, 1, 2 + amounts = np.array([0.33, 0.34, 0.33]) + + source = TemporalResolutionEnum.year + target = TemporalResolutionEnum.month + + # Test the conversion factor logic + # Year to month should multiply by 12 + # Each year (0, 1, 2) should expand to 12 months + factor = 12 # year to month + + new_indices = [] + new_amounts = [] + for idx, amount in zip(time_indices, amounts): + base_idx = int(idx * factor) + expanded_indices = np.arange(base_idx, base_idx + factor) + expanded_amounts = np.full(factor, amount / factor) + new_indices.extend(expanded_indices) + new_amounts.extend(expanded_amounts) + + result_indices = np.array(new_indices) + result_amounts = np.array(new_amounts) + + # Year 0 should map to months 0-11 + # Year 1 should map to months 12-23 + # Year 2 should map to months 24-35 + assert len(result_indices) == 36 # 3 years * 12 months + assert 0 in result_indices + assert 11 in result_indices + assert 12 in result_indices + assert 35 in result_indices + + # Total amount should be preserved + assert np.isclose(result_amounts.sum(), amounts.sum()) + + def test_month_to_day_conversion(self): + """Test converting monthly indices to daily.""" + time_indices = np.array([0, 1]) # Months 0, 1 + amounts = np.array([0.5, 0.5]) + + factor = 30 # month to day (approximate) + + new_indices = [] + new_amounts = [] + for idx, amount in zip(time_indices, amounts): + base_idx = int(idx * factor) + expanded_indices = np.arange(base_idx, base_idx + factor) + expanded_amounts = np.full(factor, amount / factor) + new_indices.extend(expanded_indices) + new_amounts.extend(expanded_amounts) + + result_indices = np.array(new_indices) + result_amounts = np.array(new_amounts) + + # Month 0 should map to days 0-29 + # Month 1 should map to days 30-59 + assert len(result_indices) == 60 # 2 months * 30 days + assert 0 in result_indices + assert 29 in result_indices + assert 30 in result_indices + + # Total amount should be preserved + assert np.isclose(result_amounts.sum(), amounts.sum()) + + def test_same_resolution_no_change(self): + """Test that same resolution returns unchanged values.""" + time_indices = np.array([0, 1, 2, 3]) + amounts = np.array([0.25, 0.25, 0.25, 0.25]) + + # Same resolution should return identical values + result_indices = time_indices.copy() + result_amounts = amounts.copy() + + assert np.array_equal(result_indices, time_indices) + assert np.array_equal(result_amounts, amounts) From 730f88aedc04969d8ed9ced2ff58990440508a11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:33:09 +0000 Subject: [PATCH 6/9] Consolidate conversion constants and improve documentation Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- optimex/lca_processor.py | 57 ++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 072cacb..3898913 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -110,33 +110,12 @@ def get_finest(*resolutions: "TemporalResolutionEnum") -> "TemporalResolutionEnu """Return the finest (most granular) resolution from a list of resolutions.""" return max(resolutions, key=lambda r: r.priority) - def convert_to(self, value: int, target: "TemporalResolutionEnum") -> int: - """ - Convert a time index from this resolution to target resolution. - - Note: Conversion from coarser to finer resolution maps to the start of the period. - For example, year 1 -> month 12 (start of year 1 in months). - """ - if self == target: - return value - - # Convert to days as intermediate representation, then to target - if self == TemporalResolutionEnum.year: - days = value * 365 # Approximate - months = value * 12 - elif self == TemporalResolutionEnum.month: - days = value * 30 # Approximate - months = value - else: # day - days = value - months = value // 30 # Approximate - - if target == TemporalResolutionEnum.day: - return days - elif target == TemporalResolutionEnum.month: - return months - else: # year - return days // 365 if self == TemporalResolutionEnum.day else value // 12 + +# Conversion constants used throughout the module for consistency +# These are approximations - exact calendar calculations are not used for simplicity +DAYS_PER_YEAR = 365 # Approximate (ignores leap years) +DAYS_PER_MONTH = 30 # Approximate average +MONTHS_PER_YEAR = 12 # Exact def detect_temporal_resolution(td: TemporalDistribution) -> TemporalResolutionEnum: @@ -528,21 +507,25 @@ def _convert_temporal_resolution( ------- Tuple[np.ndarray, np.ndarray] Converted time indices and amounts in target resolution. + + Notes + ----- + Conversion uses approximate factors defined in module constants: + - MONTHS_PER_YEAR = 12 (exact) + - DAYS_PER_MONTH = 30 (approximate average) + - DAYS_PER_YEAR = 365 (approximate, ignores leap years) """ if source_resolution == target_resolution: return time_indices, amounts - # Define conversion factors - # year -> month: multiply by 12 - # month -> day: multiply by 30 (approximate) - # year -> day: multiply by 365 (approximate) + # Define conversion factors using module constants conversion_factors = { - (TemporalResolutionEnum.year, TemporalResolutionEnum.month): 12, - (TemporalResolutionEnum.year, TemporalResolutionEnum.day): 365, - (TemporalResolutionEnum.month, TemporalResolutionEnum.day): 30, - (TemporalResolutionEnum.month, TemporalResolutionEnum.year): 1/12, - (TemporalResolutionEnum.day, TemporalResolutionEnum.month): 1/30, - (TemporalResolutionEnum.day, TemporalResolutionEnum.year): 1/365, + (TemporalResolutionEnum.year, TemporalResolutionEnum.month): MONTHS_PER_YEAR, + (TemporalResolutionEnum.year, TemporalResolutionEnum.day): DAYS_PER_YEAR, + (TemporalResolutionEnum.month, TemporalResolutionEnum.day): DAYS_PER_MONTH, + (TemporalResolutionEnum.month, TemporalResolutionEnum.year): 1 / MONTHS_PER_YEAR, + (TemporalResolutionEnum.day, TemporalResolutionEnum.month): 1 / DAYS_PER_MONTH, + (TemporalResolutionEnum.day, TemporalResolutionEnum.year): 1 / DAYS_PER_YEAR, } factor = conversion_factors.get((source_resolution, target_resolution)) From be937939eb1404949fc8e59232e903a973c146dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:41:33 +0000 Subject: [PATCH 7/9] Add SEASONAL metric for dynamic monthly characterization factors (water scarcity) Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- notebooks/monthly_resolution_example.ipynb | 47 ++++++++++++++---- optimex/lca_processor.py | 55 +++++++++++++++++++++- tests/test_temporal_resolution.py | 42 +++++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/notebooks/monthly_resolution_example.ipynb b/notebooks/monthly_resolution_example.ipynb index f6b334c..38f4535 100644 --- a/notebooks/monthly_resolution_example.ipynb +++ b/notebooks/monthly_resolution_example.ipynb @@ -264,13 +264,36 @@ " ]\n", ")\n", "\n", - "# Water scarcity method (using static characterization for now)\n", - "# In practice, this could vary by season - higher scarcity in summer\n", + "# Water scarcity method - base characterization factor\n", + "# The actual seasonal variation will be applied via SEASONAL metric in LCA config\n", "bd.Method((\"water_scarcity\", \"monthly_example\")).write(\n", " [\n", - " ((\"biosphere3\", \"water\"), 1), # 1 L water eq per L water\n", + " ((\"biosphere3\", \"water\"), 1), # Base: 1 L water eq per L water\n", " ]\n", - ")" + ")\n", + "\n", + "# Define seasonal water scarcity factors (monthly multipliers)\n", + "# Higher scarcity in dry summer months, lower in wet winter months\n", + "# Based on typical European water stress patterns\n", + "monthly_water_scarcity_factors = {\n", + " 1: 0.6, # January - Low scarcity (wet, cold)\n", + " 2: 0.7, # February\n", + " 3: 0.8, # March - Spring transition\n", + " 4: 1.0, # April\n", + " 5: 1.2, # May - Increasing scarcity\n", + " 6: 1.5, # June - High scarcity (dry, hot)\n", + " 7: 1.8, # July - Peak scarcity\n", + " 8: 1.8, # August - Peak scarcity\n", + " 9: 1.3, # September - Declining\n", + " 10: 1.0, # October\n", + " 11: 0.8, # November - Wet autumn\n", + " 12: 0.6, # December - Low scarcity\n", + "}\n", + "\n", + "print(\"Monthly water scarcity factors:\")\n", + "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n", + "for month, factor in monthly_water_scarcity_factors.items():\n", + " print(f\" {month_names[month-1]}: {factor:.1f}x\")" ] }, { @@ -306,6 +329,7 @@ "electricity_product = bd.get_node(database=\"foreground\", code=\"electricity\")\n", "\n", "# Configure LCA with MONTHLY resolution\n", + "# Note: water_scarcity uses SEASONAL metric with monthly characterization factors\n", "lca_config = lca_processor.LCAConfig(\n", " demand={electricity_product: td_demand},\n", " temporal={\n", @@ -321,6 +345,8 @@ " {\n", " \"category_name\": \"water_scarcity\",\n", " \"brightway_method\": (\"water_scarcity\", \"monthly_example\"),\n", + " \"metric\": \"SEASONAL\", # <-- Dynamic seasonal characterization\n", + " \"seasonal_factors\": monthly_water_scarcity_factors, # Monthly multipliers\n", " },\n", " ],\n", " background_inventory={\n", @@ -329,7 +355,8 @@ " },\n", ")\n", "\n", - "print(f\"Temporal resolution: {lca_config.temporal.temporal_resolution}\")" + "print(f\"Temporal resolution: {lca_config.temporal.temporal_resolution}\")\n", + "print(f\"Water scarcity uses SEASONAL characterization with monthly factors\")" ] }, { @@ -546,17 +573,19 @@ "source": [ "## 8. Summary\n", "\n", - "This notebook demonstrated how to use `optimex` with **monthly temporal resolution**:\n", + "This notebook demonstrated how to use `optimex` with **monthly temporal resolution** and **seasonal dynamic characterization**:\n", "\n", "1. **Temporal distributions** use `timedelta64[M]` for monthly offsets\n", "2. **LCA configuration** sets `temporal_resolution: \"month\"`\n", "3. **Seasonal patterns** can be modeled by varying amounts across months\n", - "4. The optimizer accounts for seasonal supply variations when meeting demand\n", + "4. **Dynamic characterization** via `metric: \"SEASONAL\"` with `seasonal_factors` dict\n", + "5. **Water scarcity** varies monthly (higher in dry summer, lower in wet winter)\n", + "6. The optimizer accounts for seasonal supply variations and impact seasonality\n", "\n", "This enables more realistic modeling of:\n", "- Renewable energy seasonality\n", "- Agricultural cycles\n", - "- Seasonal water availability\n", + "- **Seasonal water scarcity impacts**\n", "- Monthly demand patterns" ] } @@ -582,4 +611,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 3898913..54aebd5 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -33,10 +33,12 @@ class MetricEnum(str, Enum): Attributes: GWP: Global Warming Potential - time-dependent radiative forcing metric CRF: Cumulative Radiative Forcing - integrated radiative forcing over time horizon + SEASONAL: Seasonal/time-varying characterization factors provided by user """ GWP = "GWP" CRF = "CRF" + SEASONAL = "SEASONAL" class TemporalResolutionEnum(str, Enum): @@ -200,7 +202,10 @@ class CharacterizationMethodConfig(BaseModel): (e.g., ('GWP', 'example') or ('IPCC', 'climate change', 'GWP 100a')). metric: Impact metric used for dynamic characterization. None implies static method. - Supported values: 'GWP', 'CRF'. + Supported values: 'GWP', 'CRF', 'SEASONAL'. + seasonal_factors: Optional dict mapping month number (1-12) to characterization + factor multipliers. Required when metric='SEASONAL'. Applied multiplicatively + to base characterization factors from brightway_method. """ category_name: str = Field( @@ -220,7 +225,13 @@ class CharacterizationMethodConfig(BaseModel): metric: Optional[MetricEnum] = Field( None, description="Impact metric for dynamic characterization. " - "Use None for static methods.", + "Use None for static methods. 'SEASONAL' for user-provided time-varying factors.", + ) + seasonal_factors: Optional[Dict[int, float]] = Field( + None, + description="Optional dict mapping month number (1-12) to characterization " + "factor multipliers. Required when metric='SEASONAL'. Applied multiplicatively " + "to base characterization factors from brightway_method.", ) @property @@ -1199,6 +1210,46 @@ def _construct_characterization_tensor(self) -> None: f"Dynamic CRF characterization for {category_name} completed." ) + elif metric == MetricEnum.SEASONAL: + # Seasonal/time-varying characterization factors + # Get base characterization factors from the method + method_data = bd.Method(method).load() + method_dict = {flow: value for flow, value in method_data if value != 0} + + # Get seasonal factors from config + seasonal_factors = config.seasonal_factors + if seasonal_factors is None: + raise ValueError( + f"seasonal_factors must be provided when metric='SEASONAL' " + f"for category '{category_name}'" + ) + + for _, row in df.iterrows(): + flow_code, flow_id = row["code"], row["flow"] + if flow_id in method_dict: + base_cf = method_dict[flow_id] + + for time_idx in system_time_list: + # Get the corresponding date for this time index + date = time_index_to_date.get(time_idx) + if date is not None: + # Extract month (1-12) from the date + month = date.month + # Get seasonal factor, default to 1.0 if not specified + seasonal_factor = seasonal_factors.get(month, 1.0) + cf = base_cf * seasonal_factor + else: + cf = base_cf + + characterization_tensor[ + (category_name, flow_code, time_idx) + ] = cf + + logger.info( + f"Seasonal characterization for {category_name} completed " + f"with {len(seasonal_factors)} monthly factors." + ) + else: raise ValueError(f"Unsupported dynamic metric: {metric}") diff --git a/tests/test_temporal_resolution.py b/tests/test_temporal_resolution.py index e09314d..b26f53f 100644 --- a/tests/test_temporal_resolution.py +++ b/tests/test_temporal_resolution.py @@ -445,3 +445,45 @@ def test_same_resolution_no_change(self): assert np.array_equal(result_indices, time_indices) assert np.array_equal(result_amounts, amounts) + + +class TestSeasonalCharacterization: + """Tests for seasonal/dynamic characterization factors.""" + + def test_seasonal_metric_enum_exists(self): + """Test that SEASONAL metric is available in the enum.""" + from optimex.lca_processor import MetricEnum + assert MetricEnum.SEASONAL == "SEASONAL" + + def test_seasonal_factors_config_accepted(self): + """Test that seasonal_factors config is accepted.""" + from optimex.lca_processor import CharacterizationMethodConfig + + seasonal_factors = { + 1: 0.6, 2: 0.7, 3: 0.8, 4: 1.0, 5: 1.2, 6: 1.5, + 7: 1.8, 8: 1.8, 9: 1.3, 10: 1.0, 11: 0.8, 12: 0.6, + } + + config = CharacterizationMethodConfig( + category_name="water_scarcity", + brightway_method=("water", "test"), + metric="SEASONAL", + seasonal_factors=seasonal_factors, + ) + + assert config.metric.value == "SEASONAL" + assert config.seasonal_factors == seasonal_factors + assert config.dynamic is True + + def test_static_method_no_seasonal_factors(self): + """Test that static methods don't require seasonal_factors.""" + from optimex.lca_processor import CharacterizationMethodConfig + + config = CharacterizationMethodConfig( + category_name="climate_change", + brightway_method=("GWP", "test"), + metric=None, + ) + + assert config.seasonal_factors is None + assert config.dynamic is False From 9e7bc008364fa473d1fbf6bf832f3217a0e6ed77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:37:26 +0000 Subject: [PATCH 8/9] Refactor to generic characterization interface with user-provided factors Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- notebooks/monthly_resolution_example.ipynb | 59 +++++---- optimex/lca_processor.py | 138 ++++++++++----------- tests/test_temporal_resolution.py | 53 ++++---- 3 files changed, 138 insertions(+), 112 deletions(-) diff --git a/notebooks/monthly_resolution_example.ipynb b/notebooks/monthly_resolution_example.ipynb index 38f4535..ab4cc3a 100644 --- a/notebooks/monthly_resolution_example.ipynb +++ b/notebooks/monthly_resolution_example.ipynb @@ -264,18 +264,13 @@ " ]\n", ")\n", "\n", - "# Water scarcity method - base characterization factor\n", - "# The actual seasonal variation will be applied via SEASONAL metric in LCA config\n", - "bd.Method((\"water_scarcity\", \"monthly_example\")).write(\n", - " [\n", - " ((\"biosphere3\", \"water\"), 1), # Base: 1 L water eq per L water\n", - " ]\n", - ")\n", + "# For water scarcity, we'll provide characterization factors directly\n", + "# No need for a Brightway method - we'll use user-provided time-varying factors\n", "\n", - "# Define seasonal water scarcity factors (monthly multipliers)\n", + "# Define monthly water scarcity characterization factors\n", "# Higher scarcity in dry summer months, lower in wet winter months\n", "# Based on typical European water stress patterns\n", - "monthly_water_scarcity_factors = {\n", + "monthly_water_cf = {\n", " 1: 0.6, # January - Low scarcity (wet, cold)\n", " 2: 0.7, # February\n", " 3: 0.8, # March - Spring transition\n", @@ -290,10 +285,10 @@ " 12: 0.6, # December - Low scarcity\n", "}\n", "\n", - "print(\"Monthly water scarcity factors:\")\n", + "print(\"Monthly water scarcity characterization factors:\")\n", "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n", - "for month, factor in monthly_water_scarcity_factors.items():\n", - " print(f\" {month_names[month-1]}: {factor:.1f}x\")" + "for month, cf in monthly_water_cf.items():\n", + " print(f\" {month_names[month-1]}: {cf:.1f} L-eq/L\")" ] }, { @@ -328,8 +323,20 @@ "# Get product node for demand specification\n", "electricity_product = bd.get_node(database=\"foreground\", code=\"electricity\")\n", "\n", + "# Build water scarcity characterization factors dict\n", + "# Keys are (flow_code, time_index) tuples\n", + "# Time indices for monthly resolution: year*12 + (month-1)\n", + "water_cf_factors = {}\n", + "for m in range(n_months):\n", + " year = 2024 + m // 12\n", + " month = (m % 12) + 1\n", + " time_idx = year * 12 + month - 1 # Monthly time index\n", + " water_cf_factors[(\"water\", time_idx)] = monthly_water_cf[month]\n", + "\n", + "print(f\"Created {len(water_cf_factors)} water scarcity characterization factors\")\n", + "\n", "# Configure LCA with MONTHLY resolution\n", - "# Note: water_scarcity uses SEASONAL metric with monthly characterization factors\n", + "# Note: water_scarcity uses user-provided characterization_factors\n", "lca_config = lca_processor.LCAConfig(\n", " demand={electricity_product: td_demand},\n", " temporal={\n", @@ -340,13 +347,13 @@ " characterization_methods=[\n", " {\n", " \"category_name\": \"climate_change\",\n", - " \"brightway_method\": (\"GWP\", \"monthly_example\"),\n", + " \"brightway_method\": (\"GWP\", \"monthly_example\"), # Static Brightway method\n", " },\n", " {\n", " \"category_name\": \"water_scarcity\",\n", - " \"brightway_method\": (\"water_scarcity\", \"monthly_example\"),\n", - " \"metric\": \"SEASONAL\", # <-- Dynamic seasonal characterization\n", - " \"seasonal_factors\": monthly_water_scarcity_factors, # Monthly multipliers\n", + " # User-provided time-varying characterization factors\n", + " # No brightway_method needed - factors provided directly!\n", + " \"characterization_factors\": water_cf_factors,\n", " },\n", " ],\n", " background_inventory={\n", @@ -356,7 +363,7 @@ ")\n", "\n", "print(f\"Temporal resolution: {lca_config.temporal.temporal_resolution}\")\n", - "print(f\"Water scarcity uses SEASONAL characterization with monthly factors\")" + "print(f\"Water scarcity uses user-provided time-varying characterization factors\")" ] }, { @@ -573,20 +580,30 @@ "source": [ "## 8. Summary\n", "\n", - "This notebook demonstrated how to use `optimex` with **monthly temporal resolution** and **seasonal dynamic characterization**:\n", + "This notebook demonstrated how to use `optimex` with **monthly temporal resolution** and **user-provided dynamic characterization factors**:\n", "\n", "1. **Temporal distributions** use `timedelta64[M]` for monthly offsets\n", "2. **LCA configuration** sets `temporal_resolution: \"month\"`\n", "3. **Seasonal patterns** can be modeled by varying amounts across months\n", - "4. **Dynamic characterization** via `metric: \"SEASONAL\"` with `seasonal_factors` dict\n", + "4. **User-provided characterization factors** via `characterization_factors` dict\n", + " - Maps `(flow_code, time_index)` tuples to CF values\n", + " - No Brightway method needed for user-defined factors\n", "5. **Water scarcity** varies monthly (higher in dry summer, lower in wet winter)\n", "6. The optimizer accounts for seasonal supply variations and impact seasonality\n", "\n", + "### Characterization Options\n", + "\n", + "The new generic interface supports three ways to specify characterization:\n", + "\n", + "1. **Static Brightway method**: `brightway_method=(\"GWP\", \"example\")`\n", + "2. **Dynamic via dynamic_characterization**: `brightway_method=..., metric=\"GWP\"` or `\"CRF\"`\n", + "3. **User-provided factors**: `characterization_factors={(\"flow\", time_idx): value, ...}`\n", + "\n", "This enables more realistic modeling of:\n", "- Renewable energy seasonality\n", "- Agricultural cycles\n", "- **Seasonal water scarcity impacts**\n", - "- Monthly demand patterns" + "- Any time-varying characterization factors" ] } ], diff --git a/optimex/lca_processor.py b/optimex/lca_processor.py index 54aebd5..d304b73 100644 --- a/optimex/lca_processor.py +++ b/optimex/lca_processor.py @@ -28,17 +28,15 @@ class MetricEnum(str, Enum): """ - Supported metrics for dynamic impact characterization. + Supported metrics for dynamic impact characterization using the dynamic_characterization package. Attributes: GWP: Global Warming Potential - time-dependent radiative forcing metric CRF: Cumulative Radiative Forcing - integrated radiative forcing over time horizon - SEASONAL: Seasonal/time-varying characterization factors provided by user """ GWP = "GWP" CRF = "CRF" - SEASONAL = "SEASONAL" class TemporalResolutionEnum(str, Enum): @@ -195,17 +193,32 @@ class CharacterizationMethodConfig(BaseModel): """ Configuration for a single LCIA characterization method. + Users can specify characterization factors in three ways: + + 1. **Static Brightway method**: Provide `brightway_method` with `metric=None` + - Uses constant characterization factors from the Brightway method + + 2. **Dynamic characterization (GWP/CRF)**: Provide `brightway_method` with `metric="GWP"` or `"CRF"` + - Uses the `dynamic_characterization` package to compute time-varying factors + + 3. **User-provided factors**: Provide `characterization_factors` directly + - Dict mapping (flow_code, time_index) tuples to characterization factor values + - Allows full flexibility for any time-varying characterization (e.g., seasonal water scarcity) + - Can be combined with `brightway_method` (factors override/supplement method values) + Attributes: category_name: User-defined identifier for the impact category (e.g., 'climate_change_dynamic_gwp'). - brightway_method: Brightway method identifier tuple, either 2 or 3 elements - (e.g., ('GWP', 'example') or ('IPCC', 'climate change', 'GWP 100a')). - metric: Impact metric used for dynamic characterization. - None implies static method. - Supported values: 'GWP', 'CRF', 'SEASONAL'. - seasonal_factors: Optional dict mapping month number (1-12) to characterization - factor multipliers. Required when metric='SEASONAL'. Applied multiplicatively - to base characterization factors from brightway_method. + brightway_method: Optional Brightway method identifier tuple. + Required for static methods and dynamic GWP/CRF. Optional when providing + characterization_factors directly. + metric: Impact metric used for dynamic characterization via dynamic_characterization package. + None implies static method or user-provided factors. + Supported values: 'GWP', 'CRF'. + characterization_factors: Optional dict mapping (flow_code, time_index) tuples to + characterization factor values. Provides full flexibility for time-varying + characterization without using the dynamic_characterization package. + Example: {("CO2", 24240): 1.0, ("CO2", 24241): 1.1, ...} for monthly indices. """ category_name: str = Field( @@ -213,31 +226,32 @@ class CharacterizationMethodConfig(BaseModel): description="User-defined name for the impact category " "(e.g., 'climate_change_dynamic_gwp').", ) - brightway_method: Union[ + brightway_method: Optional[Union[ Tuple[str, str], Tuple[str, str, str], Tuple[str, str, str, str] - ] = Field( - ..., + ]] = Field( + None, description=( - "The Brightway method tuple with 2 to 4 elements " - "(e.g., ('IPCC', 'climate change', 'GWP 100a'))." + "Optional Brightway method tuple with 2 to 4 elements " + "(e.g., ('IPCC', 'climate change', 'GWP 100a')). " + "Required for static methods and dynamic GWP/CRF." ), ) metric: Optional[MetricEnum] = Field( None, - description="Impact metric for dynamic characterization. " - "Use None for static methods. 'SEASONAL' for user-provided time-varying factors.", + description="Impact metric for dynamic characterization using dynamic_characterization package. " + "Use None for static methods or when providing characterization_factors directly.", ) - seasonal_factors: Optional[Dict[int, float]] = Field( + characterization_factors: Optional[Dict[Tuple[str, int], float]] = Field( None, - description="Optional dict mapping month number (1-12) to characterization " - "factor multipliers. Required when metric='SEASONAL'. Applied multiplicatively " - "to base characterization factors from brightway_method.", + description="Optional dict mapping (flow_code, time_index) tuples to characterization " + "factor values. Allows user-provided time-varying characterization factors. " + "Example: {('CO2', 24240): 1.0, ('water', 24243): 1.5}", ) @property def dynamic(self) -> bool: """Indicates whether this is a dynamic characterization method.""" - return self.metric is not None + return self.metric is not None or self.characterization_factors is not None class TemporalConfig(BaseModel): @@ -1110,13 +1124,26 @@ def _construct_characterization_tensor(self) -> None: self._category.add(category_name) method = config.brightway_method metric = config.metric + user_factors = config.characterization_factors df = flow_df.copy() df["amount"] = 1 df["activity"] = np.nan - if metric is None: - # Static LCIA + # Case 1: User-provided characterization factors (most flexible) + if user_factors is not None: + # User provides dict mapping (flow_code, time_index) -> CF value + for (flow_code, time_idx), cf_value in user_factors.items(): + if flow_code in flow_codes and time_idx in system_time_list: + characterization_tensor[(category_name, flow_code, time_idx)] = cf_value + + logger.info( + f"User-provided characterization for {category_name} completed " + f"with {len(user_factors)} factors." + ) + + # Case 2: Static LCIA from Brightway method + elif metric is None and method is not None: method_data = bd.Method(method).load() method_dict = {flow: value for flow, value in method_data if value != 0} @@ -1131,8 +1158,12 @@ def _construct_characterization_tensor(self) -> None: f"Static characterization for method {category_name} completed." ) - elif metric == "GWP": - # Dynamic GWP (time-specific values) + # Case 3: Dynamic GWP from dynamic_characterization package + elif metric == MetricEnum.GWP: + if method is None: + raise ValueError( + f"brightway_method required for GWP metric in category '{category_name}'" + ) df = df.loc[np.repeat(df.index, len(dates))].reset_index(drop=True) df["date"] = np.tile(dates, len(flow_codes)) df["date"] = df["date"].astype("datetime64[s]") @@ -1162,8 +1193,12 @@ def _construct_characterization_tensor(self) -> None: f"Dynamic GWP characterization for {category_name} completed." ) - elif metric == "CRF": - # Dynamic CRF (cumulative RF over time horizon) + # Case 4: Dynamic CRF from dynamic_characterization package + elif metric == MetricEnum.CRF: + if method is None: + raise ValueError( + f"brightway_method required for CRF metric in category '{category_name}'" + ) df["date"] = pd.Timestamp(self.config.temporal.start_date) for _, row in df.iterrows(): @@ -1210,47 +1245,10 @@ def _construct_characterization_tensor(self) -> None: f"Dynamic CRF characterization for {category_name} completed." ) - elif metric == MetricEnum.SEASONAL: - # Seasonal/time-varying characterization factors - # Get base characterization factors from the method - method_data = bd.Method(method).load() - method_dict = {flow: value for flow, value in method_data if value != 0} - - # Get seasonal factors from config - seasonal_factors = config.seasonal_factors - if seasonal_factors is None: - raise ValueError( - f"seasonal_factors must be provided when metric='SEASONAL' " - f"for category '{category_name}'" - ) - - for _, row in df.iterrows(): - flow_code, flow_id = row["code"], row["flow"] - if flow_id in method_dict: - base_cf = method_dict[flow_id] - - for time_idx in system_time_list: - # Get the corresponding date for this time index - date = time_index_to_date.get(time_idx) - if date is not None: - # Extract month (1-12) from the date - month = date.month - # Get seasonal factor, default to 1.0 if not specified - seasonal_factor = seasonal_factors.get(month, 1.0) - cf = base_cf * seasonal_factor - else: - cf = base_cf - - characterization_tensor[ - (category_name, flow_code, time_idx) - ] = cf - - logger.info( - f"Seasonal characterization for {category_name} completed " - f"with {len(seasonal_factors)} monthly factors." - ) - else: - raise ValueError(f"Unsupported dynamic metric: {metric}") + raise ValueError( + f"Invalid characterization config for '{category_name}': " + f"provide either brightway_method (with optional metric) or characterization_factors" + ) self._characterization.update(characterization_tensor) diff --git a/tests/test_temporal_resolution.py b/tests/test_temporal_resolution.py index b26f53f..efa5ba5 100644 --- a/tests/test_temporal_resolution.py +++ b/tests/test_temporal_resolution.py @@ -447,36 +447,33 @@ def test_same_resolution_no_change(self): assert np.array_equal(result_amounts, amounts) -class TestSeasonalCharacterization: - """Tests for seasonal/dynamic characterization factors.""" +class TestUserProvidedCharacterization: + """Tests for user-provided characterization factors.""" - def test_seasonal_metric_enum_exists(self): - """Test that SEASONAL metric is available in the enum.""" - from optimex.lca_processor import MetricEnum - assert MetricEnum.SEASONAL == "SEASONAL" - - def test_seasonal_factors_config_accepted(self): - """Test that seasonal_factors config is accepted.""" + def test_characterization_factors_config_accepted(self): + """Test that characterization_factors config is accepted.""" from optimex.lca_processor import CharacterizationMethodConfig - seasonal_factors = { - 1: 0.6, 2: 0.7, 3: 0.8, 4: 1.0, 5: 1.2, 6: 1.5, - 7: 1.8, 8: 1.8, 9: 1.3, 10: 1.0, 11: 0.8, 12: 0.6, + # User-provided factors mapping (flow_code, time_index) -> CF value + user_factors = { + ("water", 24240): 0.6, # Jan 2020 + ("water", 24241): 0.7, # Feb 2020 + ("water", 24242): 0.8, # Mar 2020 + ("water", 24246): 1.5, # Jul 2020 (peak scarcity) } config = CharacterizationMethodConfig( category_name="water_scarcity", - brightway_method=("water", "test"), - metric="SEASONAL", - seasonal_factors=seasonal_factors, + characterization_factors=user_factors, ) - assert config.metric.value == "SEASONAL" - assert config.seasonal_factors == seasonal_factors - assert config.dynamic is True + assert config.characterization_factors == user_factors + assert config.brightway_method is None + assert config.metric is None + assert config.dynamic is True # User factors make it dynamic - def test_static_method_no_seasonal_factors(self): - """Test that static methods don't require seasonal_factors.""" + def test_static_method_config(self): + """Test that static Brightway methods work.""" from optimex.lca_processor import CharacterizationMethodConfig config = CharacterizationMethodConfig( @@ -485,5 +482,19 @@ def test_static_method_no_seasonal_factors(self): metric=None, ) - assert config.seasonal_factors is None + assert config.brightway_method == ("GWP", "test") + assert config.characterization_factors is None assert config.dynamic is False + + def test_dynamic_gwp_metric_config(self): + """Test that dynamic GWP metric config works.""" + from optimex.lca_processor import CharacterizationMethodConfig, MetricEnum + + config = CharacterizationMethodConfig( + category_name="climate_change_dynamic", + brightway_method=("GWP", "test"), + metric="GWP", + ) + + assert config.metric == MetricEnum.GWP + assert config.dynamic is True From 24ff1a9ef05a4accdcb30460efe5166be09feb36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 08:12:50 +0000 Subject: [PATCH 9/9] Add documentation for temporal resolution and characterization interface Co-authored-by: TimoDiepers <90762029+TimoDiepers@users.noreply.github.com> --- CHANGES.md | 11 + docs/content/examples/index.md | 12 + .../examples/monthly_resolution_example.ipynb | 631 ++++++++++++++++++ docs/content/foreground_modeling.md | 76 +++ docs/content/optimization_setup.md | 130 +++- 5 files changed, 851 insertions(+), 9 deletions(-) create mode 100644 docs/content/examples/monthly_resolution_example.ipynb diff --git a/CHANGES.md b/CHANGES.md index 99250b3..4646b15 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +* **Flexible temporal resolution**: Support for `"year"`, `"month"`, and `"day"` resolutions via `temporal_resolution` config + - Monthly resolution enables seasonal modeling (e.g., renewable intermittency, water scarcity) + - Daily resolution for fine-grained operations and storage optimization + - Automatic conversion between resolutions (yearly→monthly→daily) +* **Mixed temporal resolutions**: Processes can use different resolutions in the same model + - `detect_temporal_resolution()` infers resolution from TemporalDistribution values + - Coarser resolutions automatically expanded to match configured resolution +* **User-provided characterization factors**: New `characterization_factors` field for custom time-varying LCIA + - Maps `(flow_code, time_index)` tuples directly to characterization factor values + - No Brightway method required - full flexibility for seasonal/regionalized factors + - Example: seasonal water scarcity factors varying by month * **Vintage-dependent foreground parameters**: Model how process characteristics change based on installation year (vintage). Supports two approaches: - Explicit values per vintage via `foreground_*_vintages` fields - Scaling factors via `technology_evolution` field diff --git a/docs/content/examples/index.md b/docs/content/examples/index.md index 76a9e88..021b4fe 100644 --- a/docs/content/examples/index.md +++ b/docs/content/examples/index.md @@ -28,6 +28,18 @@ Here are some examples on how you can use the `optimex` package. *by @TimoDiepers & @JanTautorus* +- :lucide-sun:{ style="color: #ffd43b" } **Monthly Resolution Example** + + --- + + Seasonal renewable electricity with monthly temporal resolution. Demonstrates user-provided time-varying characterization factors for water scarcity. + + [:lucide-arrow-right: View Example](./monthly_resolution_example.ipynb) + + --- + + *Monthly resolution & dynamic characterization* + - :lucide-hourglass:{ style="color: #ffa94d" } **More Examples coming soon...** --- diff --git a/docs/content/examples/monthly_resolution_example.ipynb b/docs/content/examples/monthly_resolution_example.ipynb new file mode 100644 index 0000000..ab4cc3a --- /dev/null +++ b/docs/content/examples/monthly_resolution_example.ipynb @@ -0,0 +1,631 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "89a21041", + "metadata": {}, + "source": [ + "# Monthly Resolution Example: Seasonal Renewable Electricity\n", + "\n", + "This notebook demonstrates `optimex`'s support for **monthly temporal resolution**. It models a scenario where:\n", + "\n", + "1. **Renewable electricity availability varies seasonally** - solar PV generates more in summer months\n", + "2. **Water use characterization factors vary monthly** - water scarcity is higher in dry summer months\n", + "3. **Demand is constant** - showing how the optimizer handles seasonal supply variations\n", + "\n", + "This example showcases the new flexible temporal resolution feature that enables sub-yearly optimization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f212141", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "import numpy as np\n", + "import bw2data as bd\n", + "from bw_temporalis import TemporalDistribution\n", + "\n", + "bd.projects.set_current(\"monthly_resolution_example\")" + ] + }, + { + "cell_type": "markdown", + "id": "d3759076", + "metadata": {}, + "source": [ + "## 1. Setup Brightway Databases\n", + "\n", + "### Biosphere Database\n", + "\n", + "We define elementary flows for CO2 emissions and water consumption." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e374c20", + "metadata": {}, + "outputs": [], + "source": [ + "# BIOSPHERE\n", + "biosphere_data = {\n", + " (\"biosphere3\", \"CO2\"): {\n", + " \"type\": \"emission\",\n", + " \"name\": \"carbon dioxide\",\n", + " \"CAS number\": \"000124-38-9\"\n", + " },\n", + " (\"biosphere3\", \"water\"): {\n", + " \"type\": \"emission\",\n", + " \"name\": \"water consumption\",\n", + " },\n", + "}\n", + "bd.Database(\"biosphere3\").write(biosphere_data)" + ] + }, + { + "cell_type": "markdown", + "id": "08177bd9", + "metadata": {}, + "source": [ + "### Background Database\n", + "\n", + "Simple background processes for grid electricity and natural gas." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c8ced0d", + "metadata": {}, + "outputs": [], + "source": [ + "# BACKGROUND DATABASE\n", + "background_data = {\n", + " (\"background\", \"grid_electricity\"): {\n", + " \"name\": \"Grid electricity\",\n", + " \"location\": \"EU\",\n", + " \"reference product\": \"electricity\",\n", + " \"exchanges\": [\n", + " {\"amount\": 1, \"type\": \"production\", \"input\": (\"background\", \"grid_electricity\")},\n", + " {\"amount\": 0.5, \"type\": \"biosphere\", \"input\": (\"biosphere3\", \"CO2\")}, # 500g CO2/kWh\n", + " {\"amount\": 0.01, \"type\": \"biosphere\", \"input\": (\"biosphere3\", \"water\")}, # 10L water/kWh\n", + " ],\n", + " },\n", + "}\n", + "bd.Database(\"background\").write(background_data)\n", + "bd.Database(\"background\").metadata[\"representative_time\"] = datetime(2024, 1, 1).isoformat()" + ] + }, + { + "cell_type": "markdown", + "id": "cffa77cb", + "metadata": {}, + "source": [ + "### Foreground Database: Electricity Production Options\n", + "\n", + "We model two electricity production options:\n", + "\n", + "1. **Solar PV** - Higher production in summer months (seasonal pattern)\n", + "2. **Conventional (gas backup)** - Constant production year-round\n", + "\n", + "The key innovation is that we use **monthly temporal distributions** to model seasonal variation in solar output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17bd6fac", + "metadata": {}, + "outputs": [], + "source": [ + "# Define seasonal solar production profile (12 months)\n", + "# Higher output in summer months (normalized to sum to 1)\n", + "monthly_solar_factors = np.array([\n", + " 0.04, # Jan - Low winter output\n", + " 0.05, # Feb\n", + " 0.08, # Mar - Spring increase\n", + " 0.10, # Apr\n", + " 0.12, # May\n", + " 0.13, # Jun - Peak summer\n", + " 0.13, # Jul - Peak summer\n", + " 0.12, # Aug\n", + " 0.09, # Sep - Autumn decline\n", + " 0.07, # Oct\n", + " 0.04, # Nov\n", + " 0.03, # Dec - Low winter output\n", + "])\n", + "\n", + "# Verify normalization\n", + "print(f\"Sum of monthly factors: {monthly_solar_factors.sum():.2f}\")\n", + "\n", + "# Create monthly temporal distribution for solar production\n", + "solar_production_td = TemporalDistribution(\n", + " date=np.array(range(12), dtype=\"timedelta64[M]\"),\n", + " amount=monthly_solar_factors\n", + ")\n", + "\n", + "# Constant monthly production for conventional (1/12 each month)\n", + "constant_monthly_td = TemporalDistribution(\n", + " date=np.array(range(12), dtype=\"timedelta64[M]\"),\n", + " amount=np.array([1/12] * 12)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d9e1234", + "metadata": {}, + "outputs": [], + "source": [ + "# FOREGROUND DATABASE\n", + "foreground_data = {\n", + " # Product node\n", + " (\"foreground\", \"electricity\"): {\n", + " \"name\": \"Electricity (kWh)\",\n", + " \"type\": bd.labels.product_node_default,\n", + " \"unit\": \"kWh\",\n", + " },\n", + " \n", + " # Solar PV process - seasonal output pattern\n", + " (\"foreground\", \"solar_pv\"): {\n", + " \"name\": \"Solar PV generation\",\n", + " \"location\": \"EU\",\n", + " \"type\": bd.labels.process_node_default,\n", + " \"operation_time_limits\": (0, 11), # 12 months operation\n", + " \"exchanges\": [\n", + " {\n", + " \"amount\": 1200, # 1200 kWh/year total production (100 kWh/month average)\n", + " \"type\": bd.labels.production_edge_default,\n", + " \"input\": (\"foreground\", \"electricity\"),\n", + " \"temporal_distribution\": solar_production_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 10, # 10 kg CO2 for panel manufacturing (construction phase)\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"CO2\"),\n", + " \"temporal_distribution\": TemporalDistribution(\n", + " date=np.array([0], dtype=\"timedelta64[M]\"),\n", + " amount=np.array([1])\n", + " ),\n", + " },\n", + " {\n", + " \"amount\": 50, # 50 L water for panel cleaning over year\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"water\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " ],\n", + " },\n", + " \n", + " # Conventional gas backup - constant output\n", + " (\"foreground\", \"gas_backup\"): {\n", + " \"name\": \"Gas backup generation\",\n", + " \"location\": \"EU\",\n", + " \"type\": bd.labels.process_node_default,\n", + " \"operation_time_limits\": (0, 11), # 12 months operation\n", + " \"exchanges\": [\n", + " {\n", + " \"amount\": 1200, # 1200 kWh/year (100 kWh/month)\n", + " \"type\": bd.labels.production_edge_default,\n", + " \"input\": (\"foreground\", \"electricity\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 600, # 600 kg CO2/year (50 kg/month) - higher carbon intensity\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"CO2\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " {\n", + " \"amount\": 120, # 120 L water/year for cooling\n", + " \"type\": bd.labels.biosphere_edge_default,\n", + " \"input\": (\"biosphere3\", \"water\"),\n", + " \"temporal_distribution\": constant_monthly_td,\n", + " \"operation\": True,\n", + " },\n", + " ],\n", + " },\n", + "}\n", + "\n", + "bd.Database(\"foreground\").write(foreground_data)" + ] + }, + { + "cell_type": "markdown", + "id": "7b3d4512", + "metadata": {}, + "source": [ + "### LCIA Methods\n", + "\n", + "We define two impact categories:\n", + "1. **Climate change** - Simple CO2 characterization\n", + "2. **Water scarcity** - With seasonal variation (higher impact in dry summer months)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5e67890", + "metadata": {}, + "outputs": [], + "source": [ + "# Climate change method (constant characterization factor)\n", + "bd.Method((\"GWP\", \"monthly_example\")).write(\n", + " [\n", + " ((\"biosphere3\", \"CO2\"), 1), # 1 kg CO2eq per kg CO2\n", + " ]\n", + ")\n", + "\n", + "# For water scarcity, we'll provide characterization factors directly\n", + "# No need for a Brightway method - we'll use user-provided time-varying factors\n", + "\n", + "# Define monthly water scarcity characterization factors\n", + "# Higher scarcity in dry summer months, lower in wet winter months\n", + "# Based on typical European water stress patterns\n", + "monthly_water_cf = {\n", + " 1: 0.6, # January - Low scarcity (wet, cold)\n", + " 2: 0.7, # February\n", + " 3: 0.8, # March - Spring transition\n", + " 4: 1.0, # April\n", + " 5: 1.2, # May - Increasing scarcity\n", + " 6: 1.5, # June - High scarcity (dry, hot)\n", + " 7: 1.8, # July - Peak scarcity\n", + " 8: 1.8, # August - Peak scarcity\n", + " 9: 1.3, # September - Declining\n", + " 10: 1.0, # October\n", + " 11: 0.8, # November - Wet autumn\n", + " 12: 0.6, # December - Low scarcity\n", + "}\n", + "\n", + "print(\"Monthly water scarcity characterization factors:\")\n", + "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n", + "for month, cf in monthly_water_cf.items():\n", + " print(f\" {month_names[month-1]}: {cf:.1f} L-eq/L\")" + ] + }, + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "## 2. Configure LCA with Monthly Resolution\n", + "\n", + "The key difference from yearly resolution is setting `temporal_resolution: \"month\"` in the configuration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6g7h8", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex import lca_processor\n", + "\n", + "# Define monthly demand for 24 months (2 years)\n", + "# Constant 100 kWh/month demand\n", + "n_months = 24\n", + "dates = [datetime(2024 + m // 12, (m % 12) + 1, 1).isoformat() for m in range(n_months)]\n", + "\n", + "td_demand = TemporalDistribution(\n", + " date=np.array(dates, dtype=\"datetime64[s]\"),\n", + " amount=np.array([100] * n_months), # 100 kWh per month\n", + ")\n", + "\n", + "# Get product node for demand specification\n", + "electricity_product = bd.get_node(database=\"foreground\", code=\"electricity\")\n", + "\n", + "# Build water scarcity characterization factors dict\n", + "# Keys are (flow_code, time_index) tuples\n", + "# Time indices for monthly resolution: year*12 + (month-1)\n", + "water_cf_factors = {}\n", + "for m in range(n_months):\n", + " year = 2024 + m // 12\n", + " month = (m % 12) + 1\n", + " time_idx = year * 12 + month - 1 # Monthly time index\n", + " water_cf_factors[(\"water\", time_idx)] = monthly_water_cf[month]\n", + "\n", + "print(f\"Created {len(water_cf_factors)} water scarcity characterization factors\")\n", + "\n", + "# Configure LCA with MONTHLY resolution\n", + "# Note: water_scarcity uses user-provided characterization_factors\n", + "lca_config = lca_processor.LCAConfig(\n", + " demand={electricity_product: td_demand},\n", + " temporal={\n", + " \"start_date\": datetime(2024, 1, 1),\n", + " \"temporal_resolution\": \"month\", # <-- KEY: Monthly resolution\n", + " \"time_horizon\": 100,\n", + " },\n", + " characterization_methods=[\n", + " {\n", + " \"category_name\": \"climate_change\",\n", + " \"brightway_method\": (\"GWP\", \"monthly_example\"), # Static Brightway method\n", + " },\n", + " {\n", + " \"category_name\": \"water_scarcity\",\n", + " # User-provided time-varying characterization factors\n", + " # No brightway_method needed - factors provided directly!\n", + " \"characterization_factors\": water_cf_factors,\n", + " },\n", + " ],\n", + " background_inventory={\n", + " \"cutoff\": 1e4,\n", + " \"calculation_method\": \"sequential\",\n", + " },\n", + ")\n", + "\n", + "print(f\"Temporal resolution: {lca_config.temporal.temporal_resolution}\")\n", + "print(f\"Water scarcity uses user-provided time-varying characterization factors\")" + ] + }, + { + "cell_type": "markdown", + "id": "i9j0k1l2", + "metadata": {}, + "source": [ + "## 3. Process LCA Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m3n4o5p6", + "metadata": {}, + "outputs": [], + "source": [ + "# Create LCA data processor\n", + "lca_processor_instance = lca_processor.LCADataProcessor(lca_config)\n", + "\n", + "# Inspect the results\n", + "print(f\"System time range: {min(lca_processor_instance.system_time)} to {max(lca_processor_instance.system_time)}\")\n", + "print(f\"Number of system time points: {len(lca_processor_instance.system_time)}\")\n", + "print(f\"Process time indices: {sorted(lca_processor_instance.process_time)}\")\n", + "print(f\"Processes: {list(lca_processor_instance.processes.values())}\")\n", + "print(f\"Products: {list(lca_processor_instance.products.values())}\")" + ] + }, + { + "cell_type": "markdown", + "id": "q7r8s9t0", + "metadata": {}, + "source": [ + "## 4. Examine Monthly Production Patterns\n", + "\n", + "Let's visualize how the solar PV production varies across months." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u1v2w3x4", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "# Extract solar PV production by month\n", + "production = lca_processor_instance.foreground_production\n", + "solar_production = {k[2]: v for k, v in production.items() if k[0] == 'solar_pv'}\n", + "gas_production = {k[2]: v for k, v in production.items() if k[0] == 'gas_backup'}\n", + "\n", + "months = list(range(12))\n", + "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', \n", + " 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n", + "\n", + "solar_values = [solar_production.get(m, 0) for m in months]\n", + "gas_values = [gas_production.get(m, 0) for m in months]\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "x = np.arange(12)\n", + "width = 0.35\n", + "\n", + "bars1 = ax.bar(x - width/2, solar_values, width, label='Solar PV', color='gold')\n", + "bars2 = ax.bar(x + width/2, gas_values, width, label='Gas Backup', color='gray')\n", + "\n", + "ax.set_xlabel('Month')\n", + "ax.set_ylabel('Production per process unit (kWh)')\n", + "ax.set_title('Monthly Production Pattern by Technology')\n", + "ax.set_xticks(x)\n", + "ax.set_xticklabels(month_names)\n", + "ax.legend()\n", + "ax.grid(axis='y', alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f\"\\nTotal annual production:\")\n", + "print(f\" Solar PV: {sum(solar_values):.1f} kWh\")\n", + "print(f\" Gas backup: {sum(gas_values):.1f} kWh\")" + ] + }, + { + "cell_type": "markdown", + "id": "y5z6a7b8", + "metadata": {}, + "source": [ + "## 5. Convert to Optimization Model Inputs\n", + "\n", + "Now we can convert the LCA data to optimization model inputs and run the optimizer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d0e1f2", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.converter import ModelInputManager\n", + "\n", + "# Convert LCA outputs to optimization inputs\n", + "manager = ModelInputManager(lca_processor_instance)\n", + "model_inputs = manager.get_model_inputs()\n", + "\n", + "print(f\"SYSTEM_TIME indices: {len(model_inputs.SYSTEM_TIME)} time points\")\n", + "print(f\"PROCESS_TIME indices: {model_inputs.PROCESS_TIME}\")\n", + "print(f\"PROCESS set: {model_inputs.PROCESS}\")\n", + "print(f\"PRODUCT set: {model_inputs.PRODUCT}\")" + ] + }, + { + "cell_type": "markdown", + "id": "g3h4i5j6", + "metadata": {}, + "source": [ + "## 6. Create and Solve Optimization Model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k7l8m9n0", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.optimizer import create_model, solve_model\n", + "\n", + "# Create Pyomo model minimizing climate change impact\n", + "model = create_model(\n", + " inputs=model_inputs,\n", + " name=\"monthly_electricity_optimization\",\n", + " objective_category=\"climate_change\",\n", + ")\n", + "\n", + "# Solve the model\n", + "results = solve_model(model, solver=\"glpk\")\n", + "print(f\"Solver status: {results.solver.status}\")\n", + "print(f\"Solver termination: {results.solver.termination_condition}\")" + ] + }, + { + "cell_type": "markdown", + "id": "o1p2q3r4", + "metadata": {}, + "source": [ + "## 7. Analyze Results\n", + "\n", + "Let's see how the optimizer schedules installations and operations across months." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s5t6u7v8", + "metadata": {}, + "outputs": [], + "source": [ + "from optimex.postprocessing import PostProcessor\n", + "\n", + "# Create post-processor\n", + "pp = PostProcessor(model)\n", + "\n", + "# Get installation decisions\n", + "installations = pp.get_installations()\n", + "print(\"\\nInstallation decisions:\")\n", + "print(installations)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w9x0y1z2", + "metadata": {}, + "outputs": [], + "source": [ + "# Get operation levels over time\n", + "operations = pp.get_operation()\n", + "print(\"\\nOperation levels (first 12 months):\")\n", + "print(operations.head(12))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3b4c5d6", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize operation over time\n", + "fig, ax = plt.subplots(figsize=(12, 5))\n", + "\n", + "for process in model_inputs.PROCESS:\n", + " process_ops = operations[operations['process'] == process]\n", + " if not process_ops.empty:\n", + " time_indices = process_ops['system_time'].values\n", + " operation_values = process_ops['operation'].values\n", + " ax.plot(time_indices, operation_values, label=process, marker='o', markersize=4)\n", + "\n", + "ax.set_xlabel('System Time (monthly index)')\n", + "ax.set_ylabel('Operation Level')\n", + "ax.set_title('Operation Scheduling Over 24 Months')\n", + "ax.legend()\n", + "ax.grid(alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "e7f8g9h0", + "metadata": {}, + "source": [ + "## 8. Summary\n", + "\n", + "This notebook demonstrated how to use `optimex` with **monthly temporal resolution** and **user-provided dynamic characterization factors**:\n", + "\n", + "1. **Temporal distributions** use `timedelta64[M]` for monthly offsets\n", + "2. **LCA configuration** sets `temporal_resolution: \"month\"`\n", + "3. **Seasonal patterns** can be modeled by varying amounts across months\n", + "4. **User-provided characterization factors** via `characterization_factors` dict\n", + " - Maps `(flow_code, time_index)` tuples to CF values\n", + " - No Brightway method needed for user-defined factors\n", + "5. **Water scarcity** varies monthly (higher in dry summer, lower in wet winter)\n", + "6. The optimizer accounts for seasonal supply variations and impact seasonality\n", + "\n", + "### Characterization Options\n", + "\n", + "The new generic interface supports three ways to specify characterization:\n", + "\n", + "1. **Static Brightway method**: `brightway_method=(\"GWP\", \"example\")`\n", + "2. **Dynamic via dynamic_characterization**: `brightway_method=..., metric=\"GWP\"` or `\"CRF\"`\n", + "3. **User-provided factors**: `characterization_factors={(\"flow\", time_idx): value, ...}`\n", + "\n", + "This enables more realistic modeling of:\n", + "- Renewable energy seasonality\n", + "- Agricultural cycles\n", + "- **Seasonal water scarcity impacts**\n", + "- Any time-varying characterization factors" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/content/foreground_modeling.md b/docs/content/foreground_modeling.md index 3d7952c..3cb16be 100644 --- a/docs/content/foreground_modeling.md +++ b/docs/content/foreground_modeling.md @@ -123,6 +123,82 @@ TemporalDistribution( !!! warning "Amounts must sum correctly" For production exchanges, the amounts in the temporal distribution should sum to the total production per unit. For a process producing 1 unit total over its lifetime with `amount=1`, use fractions that sum to 1. +### Monthly and Daily Temporal Distributions + +For sub-yearly optimization, use appropriate numpy timedelta units: + +| Resolution | Numpy dtype | Example | +|------------|-------------|---------| +| Yearly | `timedelta64[Y]` | `np.array([0, 1, 2], dtype="timedelta64[Y]")` | +| Monthly | `timedelta64[M]` | `np.array([0, 1, 2], dtype="timedelta64[M]")` | +| Daily | `timedelta64[D]` | `np.array([0, 1, 2], dtype="timedelta64[D]")` | + +**Monthly production pattern (e.g., seasonal solar output):** +```python +# Solar PV with seasonal variation (higher in summer) +monthly_solar_factors = np.array([ + 0.04, # Jan - low winter output + 0.05, # Feb + 0.08, # Mar - spring increase + 0.10, # Apr + 0.12, # May + 0.13, # Jun - peak summer + 0.13, # Jul - peak summer + 0.12, # Aug + 0.09, # Sep - autumn decline + 0.07, # Oct + 0.04, # Nov + 0.03, # Dec - low winter output +]) + +solar_production_td = TemporalDistribution( + date=np.array(range(12), dtype="timedelta64[M]"), + amount=monthly_solar_factors, # Sums to 1.0 +) +``` + +**Constant monthly distribution:** +```python +constant_monthly_td = TemporalDistribution( + date=np.array(range(12), dtype="timedelta64[M]"), + amount=np.array([1/12] * 12), # Equal each month +) +``` + +### Mixed Temporal Resolutions + +Processes can use different temporal resolutions in the same model. When the configured `temporal_resolution` is finer than an exchange's temporal distribution, `optimex` automatically converts: + +- **Yearly → Monthly**: Each year expands to 12 months (amounts divided by 12) +- **Monthly → Daily**: Each month expands to ~30 days (amounts divided by 30) + +```python +# A process with yearly distribution +yearly_process = { + "operation_time_limits": (0, 2), + "exchanges": [{ + "temporal_distribution": TemporalDistribution( + date=np.array([0, 1, 2], dtype="timedelta64[Y]"), + amount=np.array([0.33, 0.34, 0.33]), + ), + # ... + }], +} + +# Combined with a monthly process in the same model +monthly_process = { + "operation_time_limits": (0, 5), + "exchanges": [{ + "temporal_distribution": TemporalDistribution( + date=np.array(range(6), dtype="timedelta64[M]"), + amount=np.array([1/6] * 6), + ), + # ... + }], +} +# Both work together when temporal_resolution="month" +``` + --- ## The Operation Flag diff --git a/docs/content/optimization_setup.md b/docs/content/optimization_setup.md index 9cb1d59..f4dbd84 100644 --- a/docs/content/optimization_setup.md +++ b/docs/content/optimization_setup.md @@ -95,17 +95,78 @@ config = lca_processor.LCAConfig( | `temporal_resolution` | Time step granularity | `"year"`, `"month"`, `"day"` | | `time_horizon` | Years for impact accumulation | `100` (for GWP100) | +#### Temporal Resolution Options + +`optimex` supports flexible temporal resolution for sub-yearly optimization: + +| Resolution | Use Case | Time Index Format | +|------------|----------|-------------------| +| `"year"` | Long-term transitions (default) | Year number (e.g., 2020, 2021) | +| `"month"` | Seasonal variations, renewable intermittency | Year×12 + month-1 (e.g., 24240 for Jan 2020) | +| `"day"` | Daily operations, storage optimization | Days since epoch | + +**Yearly resolution (default):** +```python +temporal={ + "start_date": datetime(2020, 1, 1), + "temporal_resolution": "year", + "time_horizon": 100, +} +``` + +**Monthly resolution:** +```python +temporal={ + "start_date": datetime(2024, 1, 1), + "temporal_resolution": "month", + "time_horizon": 100, +} +``` + +!!! tip "Monthly Temporal Distributions" + When using monthly resolution, use `timedelta64[M]` for temporal distributions: + ```python + # Monthly production pattern (e.g., seasonal solar output) + TemporalDistribution( + date=np.array(range(12), dtype="timedelta64[M]"), + amount=np.array([0.04, 0.05, 0.08, 0.10, 0.12, 0.13, + 0.13, 0.12, 0.09, 0.07, 0.04, 0.03]), + ) + ``` + +#### Mixed Temporal Resolutions + +Processes can use different temporal resolutions in the same model. `optimex` automatically converts coarser resolutions to the configured target: + +- Yearly temporal distributions are expanded to 12 monthly values +- Monthly distributions are expanded to ~30 daily values +- Amounts are distributed uniformly across expanded time points + +```python +# Process with yearly distribution in a monthly-resolution model +yearly_td = TemporalDistribution( + date=np.array([0, 1, 2], dtype="timedelta64[Y]"), # Years + amount=np.array([0.33, 0.34, 0.33]), +) +# Automatically converted to monthly indices when temporal_resolution="month" +``` + +--- + ### Characterization Methods -Each method requires: +`optimex` supports three ways to specify characterization factors: + +| Approach | Use Case | Required Fields | +|----------|----------|-----------------| +| **Static Brightway** | Constant characterization factors | `brightway_method` | +| **Dynamic (GWP/CRF)** | Time-dependent climate impacts | `brightway_method`, `metric` | +| **User-provided** | Custom time-varying factors | `characterization_factors` | -| Field | Required | Description | -|-------|----------|-------------| -| `category_name` | Yes | Your name for this impact category | -| `brightway_method` | Yes | Tuple identifying the Brightway method | -| `metric` | No | Dynamic characterization: `"CRF"` or `"GWP"` | +#### Option 1: Static Brightway Method (default) + +Uses constant characterization factors from a Brightway LCIA method: -**Static characterization** (default): ```python { "category_name": "land_use", @@ -113,12 +174,15 @@ Each method requires: } ``` -**Dynamic characterization** (for climate change): +#### Option 2: Dynamic Characterization (GWP/CRF) + +Uses the `dynamic_characterization` package for time-dependent climate impacts: + ```python { "category_name": "climate_change", "brightway_method": ("IPCC 2021", "GWP 100a"), - "metric": "CRF", # Cumulative Radiative Forcing + "metric": "CRF", # or "GWP" } ``` @@ -128,6 +192,54 @@ Each method requires: Dynamic metrics account for when emissions occur, not just how much. +#### Option 3: User-Provided Characterization Factors + +Provides full flexibility for any time-varying characterization (e.g., seasonal water scarcity): + +```python +{ + "category_name": "water_scarcity", + "characterization_factors": { + # Maps (flow_code, time_index) -> characterization factor + ("water", 24240): 0.6, # January 2020 - low scarcity + ("water", 24241): 0.7, # February 2020 + ("water", 24245): 1.5, # June 2020 - high scarcity + ("water", 24246): 1.8, # July 2020 - peak scarcity + # ... more factors + }, +} +``` + +!!! tip "No Brightway Method Needed" + When using `characterization_factors`, you don't need a `brightway_method`. This is useful for: + + - Seasonal impact factors (water scarcity, land use) + - Regionalized characterization + - Custom impact categories not in Brightway + - Scenario-specific factors + +**Building time-varying factors programmatically:** + +```python +# Monthly water scarcity factors (higher in summer) +monthly_cf = { + 1: 0.6, 2: 0.7, 3: 0.8, 4: 1.0, 5: 1.2, 6: 1.5, + 7: 1.8, 8: 1.8, 9: 1.3, 10: 1.0, 11: 0.8, 12: 0.6, +} + +# Build characterization_factors dict for 24 months +water_cf = {} +for m in range(24): + year = 2024 + m // 12 + month = (m % 12) + 1 + time_idx = year * 12 + month - 1 # Monthly time index + water_cf[("water", time_idx)] = monthly_cf[month] + +characterization_methods=[ + {"category_name": "water_scarcity", "characterization_factors": water_cf}, +] +``` + ### Multiple Impact Categories ```python