import io import json import re import pandas as pd import streamlit as st import openpyxl from openpyxl.styles import PatternFill, Font from config.config import resource_unit_map, resource_order, method_order, resource_groups, method_groups def get_resource_to_group() -> dict[str, str]: """Map each resource name to its resource group. Returns: dict[str, str]: resource name → group name (e.g. "Arable land" → "Land"). """ return {r: g for g, resources in resource_groups.items() for r in resources} def get_resource_to_unit() -> dict[str, str]: """Map each resource name to its physical unit. Returns: dict[str, str]: resource name → unit (e.g. "Arable land" → "Mha"). """ return {r: resource_unit_map[g] for g, resources in resource_groups.items() for r in resources} def get_method_group(method: str) -> str: """Return the group a CDR method belongs to. Args: method (str): CDR method name. Returns: str: group name, or "Other" if not found. """ return next((g for g, methods in method_groups.items() if method in methods), "Other") @st.cache_data def load_slider_data(): """Load and cache the cost slider configuration from data/cost_sliders.json. Returns: dict: nested dict {method: {resource: {min, median, max}}}. """ with open("data/cost_sliders.json") as f: data = json.load(f) return data def render_resource_input(name_clean, r, tooltip=None): """Render a number_input widget for a single resource availability cap. Writes the entered value into st.session_state.resource_caps[r]. Args: name_clean (str): display label (resource name without unit suffix). r (str): canonical resource name used as the session state key. tooltip (str | None): optional help text shown on hover. Returns: None """ widget_key = f"cap_{r}" if widget_key not in st.session_state: existing = st.session_state.resource_caps.get(r, 0.0) st.session_state[widget_key] = existing if existing else None cap = st.number_input( label=name_clean, min_value=0.0, format="%.2f", value=None, placeholder="0.00", key=widget_key, help=tooltip ) st.session_state.resource_caps[r] = cap if cap is not None else 0.0 def is_valid_scenario(scenario): """Check that a scenario dict has the minimum required keys to be loaded. Args: scenario (dict): scenario data to validate. Returns: bool: True if all base keys are present, False otherwise. """ base_ok = ( isinstance(scenario, dict) and "allocations" in scenario and "resource_caps" in scenario and "method_costs" in scenario and "method_constraints" in scenario and "summary" in scenario ) if not base_ok: return False return True def is_complete_scenario(scenario): """Check that a scenario carries full optimisation outputs, not just inputs. A complete scenario can be displayed without re-running the optimisation. An incomplete scenario (e.g. exported before resource_actual_gain was added) triggers an automatic re-run on load. Args: scenario (dict): scenario data to validate. Returns: bool: True if the scenario includes all detailed output keys. """ required_extra = { "resource_usage", "resource_used_total", "resource_remaining", "resource_actual_gain", "enabled_methods", } return is_valid_scenario(scenario) and required_extra.issubset(scenario.keys()) def load_inputs_from_excel(file): """Parse an Excel workbook exported from the tool into session-state-ready dicts. Expects sheets: resource_caps, constraints, coefficients. Optional sheets: custom_resources, enabled_methods. Args: file: file-like object pointing to an .xlsx workbook. Returns: tuple: (resource_caps, constraints, costs, custom_resources, enabled_methods, warnings) where warnings is a list[str] of non-fatal import issues. """ xl = pd.read_excel(file, sheet_name=None) resource_caps = {} if "resource_caps" in xl: for _, row in xl["resource_caps"].iterrows(): resource_caps[row["Resource"]] = float(row["Available Amount"]) constraints = {} if "constraints" in xl: for _, row in xl["constraints"].iterrows(): constraints[row["method"]] = { "active": bool(row["active"]), "cap_type": str(row["cap_type (percent or absolute)"]), "cap_value": float(row["cap_value"]) } costs = {} if "coefficients" in xl: for _, row in xl["coefficients"].iterrows(): m, r, v = row["method"], row["resource"], float(row["value"]) costs.setdefault(m, {})[r] = v custom_resources = [] warnings = [] valid_resources = set(resource_order) valid_methods = set(method_order) if "custom_resources" in xl: df_custom = xl["custom_resources"] required_custom = {"name", "group", "amount", "unit", "method", "min", "median", "max"} if required_custom.issubset(df_custom.columns): for bname in df_custom["name"].dropna().unique(): df_b = df_custom[df_custom["name"] == bname] group = str(df_b["group"].iloc[0]) if group not in valid_resources: warnings.append(f"New resource \"{bname}\" - group \"{group}\" not found: check spelling.") continue methods = {} for _, row in df_b.iterrows(): m = str(row["method"]) if m not in valid_methods: warnings.append(f"New resource \"{bname}\" - method \"{m}\" not found: check spelling.") continue methods[m] = { "min": float(row["min"]), "median": float(row["median"]), "max": float(row["max"]), } custom_resources.append({ "name": str(bname), "group": group, "amount": float(df_b["amount"].iloc[0]), "unit": str(df_b["unit"].iloc[0]), "methods": methods, }) # Per-resource manual method allocation (which CDR methods are allowed to # use a given resource). Optional sheet for backward compatibility with # older templates that predate this feature. enabled_methods = {} if "enabled_methods" in xl: df_enabled = xl["enabled_methods"] required_enabled = {"resource", "method", "enabled"} if required_enabled.issubset(df_enabled.columns): for _, row in df_enabled.iterrows(): r, m = row["resource"], row["method"] enabled_methods.setdefault(r, {})[m] = bool(row["enabled"]) return resource_caps, constraints, costs, custom_resources, enabled_methods, warnings def strip_unit_suffix(resource_name: str) -> str: """Remove trailing unit suffix from a resource name. E.g. "Unspecified land (Mha/MtCO₂)" → "Unspecified land". Args: resource_name (str): raw resource name, possibly with a parenthetical unit. Returns: str: resource name without the unit suffix. """ return re.sub(r"\s*\(.*?\)\s*$", "", resource_name).strip() def neutralize_zero_coefficient_methods(method_costs: dict, method_constraints: dict) -> dict: """Return a copy of method_constraints with methods that have all-zero costs deactivated. Prevents the LP from including methods with no usable resource coefficients. Args: method_costs (dict): {method: {resource: coefficient}} cost matrix. method_constraints (dict): {method: {active, cap_type, cap_value}}. Returns: dict: modified copy of method_constraints with zero-cost methods deactivated. """ safe_constraints = {m: c.copy() for m, c in method_constraints.items()} for method, constraint in safe_constraints.items(): if not constraint.get("active", True): continue coeffs = method_costs.get(method, {}) all_zero = all(v == 0 for v in coeffs.values()) if coeffs else True if all_zero: constraint["active"] = False return safe_constraints