Spaces:
Sleeping
Sleeping
| import pandas as pd | |
| import streamlit as st | |
| from config.config import (resource_groups, resource_order, resource_tooltips, | |
| resource_unit_map, method_groups, CO2_UNIT, secondary_resources, | |
| hidden_resource_names) | |
| from utils.data_utils import render_resource_input, strip_unit_suffix, get_resource_to_group, get_resource_to_unit, get_method_group | |
| from utils.ui_helpers import show_bar | |
| from io import BytesIO | |
| def standard_resource_names(): | |
| """Return the set of all standard resource names defined in resource_groups config. | |
| Returns: | |
| set[str]: flat set of every resource name across all resource groups. | |
| """ | |
| return {r for rlist in resource_groups.values() for r in rlist} | |
| def init_custom_resources(): | |
| """Initialise custom resource session state keys and coerce amounts/coefficients to float. | |
| Ensures custom_resources, enabled_methods, and auto_disabled_methods exist in | |
| session state, then sanitises every batch so numeric fields are float (not str). | |
| Returns: | |
| None | |
| """ | |
| if "custom_resources" not in st.session_state: | |
| st.session_state.custom_resources = [] | |
| if "enabled_methods" not in st.session_state: | |
| st.session_state.enabled_methods = {} | |
| if "auto_disabled_methods" not in st.session_state: | |
| st.session_state.auto_disabled_methods = set() | |
| for batch in st.session_state.custom_resources: | |
| try: | |
| batch["amount"] = float(batch["amount"]) | |
| except (ValueError, TypeError): | |
| batch["amount"] = 0.0 | |
| for m, coefs in batch.get("methods", {}).items(): | |
| for key in ("min", "median", "max"): | |
| try: | |
| coefs[key] = float(coefs[key]) | |
| except (ValueError, TypeError): | |
| coefs[key] = 0.0 | |
| def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None, preset_resources: list | None = None): | |
| """Dialog for adding or editing a custom resource batch. | |
| Opens a modal form where the user picks a name, reference resource group, | |
| available quantity, and per-method coefficients (min/median/max). | |
| Args: | |
| cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}. | |
| edit_index (int | None): index into custom_resources to edit; None for a new batch. | |
| preset_resources (list | None): restrict the group selector to this resource list | |
| (used when launched from a group expander button). | |
| Returns: | |
| None | |
| """ | |
| if edit_index is not None and not st.session_state.get(f"_dlg_initialized_{edit_index}"): | |
| for key in list(st.session_state.keys()): | |
| if key.startswith(("_dlg_existing_enable_", | |
| "_dlg_existing_min_", "_dlg_existing_med_", "_dlg_existing_max_", | |
| "_dlg_new_chk_", "_dlg_new_min_", "_dlg_new_med_", "_dlg_new_max_")): | |
| del st.session_state[key] | |
| st.session_state[f"_dlg_initialized_{edit_index}"] = True | |
| standard_names = standard_resource_names() | |
| existing_names = { | |
| b["name"] for i, b in enumerate(st.session_state.custom_resources) | |
| if i != edit_index | |
| } | |
| existing = st.session_state.custom_resources[edit_index] if edit_index is not None else {} | |
| resource_to_group = get_resource_to_group() | |
| # If opened from a specific resource-group expander ("+ Add resource" inline | |
| # button), restrict the reference-resource choice to that group's resources | |
| # only β the user picked the group by clicking that button. | |
| group_options = preset_resources if preset_resources else resource_order | |
| if preset_resources: | |
| physical_group = resource_to_group.get(preset_resources[0], "") | |
| st.markdown( | |
| f'<p class="caption-input">Adding a new resource batch in the <strong>{physical_group}</strong> group.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<p class="dialog-title">1 β Name & group</p>', unsafe_allow_html=True) | |
| col_name, col_group = st.columns(2) | |
| with col_name: | |
| batch_name = st.text_input( | |
| "Resource name *", | |
| value=existing.get("name", ""), | |
| placeholder="e.g. Degraded forest lands β North", | |
| help="It can't match the name of an existing resource.", | |
| key="label-input-name" | |
| ) | |
| with col_group: | |
| default_group_idx = group_options.index(existing["group"]) if existing.get("group") in group_options else 0 | |
| batch_group = st.selectbox( | |
| "Resource group *", | |
| options=group_options, | |
| index=default_group_idx, | |
| help="Determines the physical unit of this resource.", | |
| disabled=bool(preset_resources) and len(group_options) == 1, | |
| key="label-input-group" | |
| ) | |
| group = resource_to_group.get(batch_group) | |
| unit = resource_unit_map.get(group, "?") | |
| st.markdown(f'<p class="caption">Unit for this group: <strong>{unit}/yr</strong></p>', unsafe_allow_html=True) | |
| st.markdown('<p class="dialog-title">2 β Available quantity</p>', unsafe_allow_html=True) | |
| batch_amount = st.number_input( | |
| label=f"Available amount ({unit}/yr)", | |
| min_value=0.0, | |
| format="%.2f", | |
| value=float(existing["amount"]) if existing.get("amount") and existing.get("amount") != 0.0 else None, | |
| help=f"Total quantity available for 1 year, in {unit}.", | |
| key="label-input-amount", | |
| placeholder="0.0" | |
| ) | |
| st.markdown('<p class="dialog-title">3 β Existing CDR methods for this resource</p>', unsafe_allow_html=True) | |
| method_configs = {} | |
| all_related_methods = {} | |
| for cdr_method, resource in cost_sliders.items(): | |
| for name, coeff in resource.items(): | |
| if batch_group == strip_unit_suffix(name): | |
| all_related_methods[cdr_method] = { | |
| "min": coeff["min"], | |
| "median": coeff["median"], | |
| "max": coeff["max"], | |
| } | |
| # Split into primary (selectable) and secondary (auto-linked in Tab 3) | |
| primary_related = { | |
| m: coeff for m, coeff in all_related_methods.items() | |
| if (m, batch_group) not in secondary_resources | |
| } | |
| secondary_related = { | |
| m: coeff for m, coeff in all_related_methods.items() | |
| if (m, batch_group) in secondary_resources | |
| } | |
| primary_by_group = {} | |
| for m, coeff in primary_related.items(): | |
| method_group = get_method_group(m) | |
| primary_by_group.setdefault(method_group, {})[m] = coeff | |
| if not primary_by_group: | |
| st.markdown('<p class="caption">No primary CDR methods use this resource directly.</p>', unsafe_allow_html=True) | |
| for method_group, methods in primary_by_group.items(): | |
| with st.expander(f"**{method_group}**", expanded=False): | |
| for m, coeff in methods.items(): | |
| already_selected = m in existing.get("methods", {}) | |
| if f"_dlg_existing_enable_{m}" not in st.session_state: | |
| st.session_state[f"_dlg_existing_enable_{m}"] = already_selected | |
| enabled = st.checkbox( | |
| f"{m}", | |
| key=f"_dlg_existing_enable_{m}", | |
| ) | |
| if not enabled: | |
| continue | |
| existing_coeff = existing.get("methods", {}).get(m, coeff) | |
| st.warning("Modifying these values only affects this resource batch: the base method coefficients are unchanged.") | |
| col_min, col_med, col_max = st.columns(3) | |
| with col_min: | |
| e_min = st.number_input( | |
| f"Min ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| format="%.10g", | |
| value=float(existing_coeff["min"]), | |
| key=f"_dlg_existing_min_{m}", | |
| ) | |
| with col_med: | |
| e_med = st.number_input( | |
| f"Median ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| format="%.10g", | |
| value=float(existing_coeff["median"]), | |
| key=f"_dlg_existing_med_{m}", | |
| ) | |
| with col_max: | |
| e_max = st.number_input( | |
| f"Max ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| format="%.10g", | |
| value=float(existing_coeff["max"]), | |
| key=f"_dlg_existing_max_{m}", | |
| ) | |
| method_configs[m] = { | |
| "min": e_min, | |
| "median": e_med, | |
| "max": e_max, | |
| } | |
| if secondary_related: | |
| names = "\n".join(f"- **{m}**" for m in secondary_related) | |
| st.info( | |
| f"""The following methods use this resource as **secondary** and may be assigned later in the **Resource Coefficients** tab:\n{names} | |
| """, | |
| icon=":material/info:", | |
| ) | |
| st.markdown('<p class="dialog-title">4 β Add additional CDR methods</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="caption">Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.</p>', unsafe_allow_html=True) | |
| already_used = set(all_related_methods.keys()) | |
| for group_name, methods in method_groups.items(): | |
| available_methods = [ | |
| m for m in methods | |
| if m not in already_used | |
| ] | |
| if not available_methods: | |
| continue | |
| with st.expander(f"**{group_name}**", expanded=False): | |
| for m in available_methods: | |
| enabled = st.checkbox( | |
| f"**{m}**", | |
| key=f"_dlg_new_chk_{m}", | |
| ) | |
| if enabled: | |
| col_min, col_med, col_max = st.columns(3) | |
| with col_min: | |
| v_min = st.number_input( | |
| f"Min ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| value=None, | |
| format="%.10g", | |
| key=f"_dlg_new_min_{m}", | |
| placeholder="0.0000", | |
| ) | |
| with col_med: | |
| v_med = st.number_input( | |
| f"Median ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| value=None, | |
| format="%.10g", | |
| key=f"_dlg_new_med_{m}", | |
| placeholder="0.0000", | |
| ) | |
| with col_max: | |
| v_max = st.number_input( | |
| f"Ma ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| value=None, | |
| format="%.10g", | |
| key=f"_dlg_new_max_{m}", | |
| placeholder="0.0000", | |
| ) | |
| method_configs[m] = { | |
| "min": v_min, | |
| "median": v_med, | |
| "max": v_max, | |
| } | |
| error_placeholder = st.empty() | |
| col_save, col_cancel = st.columns([2, 2]) | |
| with col_cancel: | |
| if st.button("Cancel", width='stretch', key="btn-reset-cancel"): | |
| st.rerun() | |
| def validate(): | |
| errors = [] | |
| if not batch_name.strip(): | |
| errors.append("Resource name is required.") | |
| elif batch_name.strip().lower() in {n.lower() for n in standard_names}: | |
| errors.append(f"**{batch_name}** matches a standard resource name. Choose a different name.") | |
| elif batch_name.strip() in existing_names: | |
| errors.append(f"**{batch_name}** already exists. Choose a different name.") | |
| if not batch_amount or batch_amount <= 0: | |
| errors.append("Available amount must be greater than 0.") | |
| if not method_configs and not secondary_related: | |
| errors.append("Select at least one CDR method.") | |
| for m, c in method_configs.items(): | |
| if any(v is None for v in [c["min"], c["median"], c["max"]]): | |
| errors.append(f"**{m}**: all coefficient fields (min, median, max) are required.") | |
| continue | |
| if c["min"] > c["max"]: | |
| errors.append(f"**{m}**: min ({c['min']}) cannot be greater than max ({c['max']}).") | |
| if not (c["min"] <= c["median"] <= c["max"]): | |
| errors.append(f"**{m}**: median must be between min and max.") | |
| return errors | |
| with col_save: | |
| if st.button("Add to session", type="tertiary", width='stretch', icon=":material/check:", key="btn-new-add"): | |
| errors = validate() | |
| if errors: | |
| with error_placeholder.container(): | |
| for e in errors: | |
| st.error(e, icon=":material/error:") | |
| else: | |
| batch = { | |
| "name": batch_name.strip(), | |
| "group": batch_group, | |
| "amount": float(batch_amount), # always store as float | |
| "unit": resource_unit_map.get(resource_to_group.get(batch_group), "?"), | |
| "methods": method_configs, | |
| } | |
| if edit_index is not None: | |
| st.session_state.custom_resources[edit_index] = batch | |
| st.session_state.pop(f"custom_amount_{edit_index}", None) | |
| else: | |
| new_idx = len(st.session_state.custom_resources) | |
| st.session_state.custom_resources.append(batch) | |
| st.session_state.pop(f"custom_amount_{new_idx}", None) | |
| st.session_state.pop("latest_result", None) | |
| st.rerun() | |
| def to_excel_bytes(batches: list) -> bytes: | |
| """Serialise all custom resource batches to an Excel file (one row per batch Γ method). | |
| Args: | |
| batches (list): list of custom batch dicts with keys name, group, amount, unit, methods. | |
| Returns: | |
| bytes: raw bytes of the .xlsx workbook. | |
| """ | |
| rows = [] | |
| for batch in batches: | |
| for m, coefs in batch["methods"].items(): | |
| rows.append({ | |
| "name": batch["name"], | |
| "group": batch["group"], | |
| "amount": float(batch["amount"]), | |
| "unit": batch["unit"], | |
| "method": m, | |
| "min": float(coefs["min"]), | |
| "median": float(coefs["median"]), | |
| "max": float(coefs["max"]), | |
| }) | |
| excel_buffer = BytesIO() | |
| with pd.ExcelWriter(excel_buffer, engine="openpyxl") as writer: | |
| pd.DataFrame(rows).to_excel(writer, index=False, sheet_name="custom_resources") | |
| return excel_buffer.getvalue() | |
| def parse_custom_excel(file) -> list: | |
| """Parse a custom resource Excel file into a list of batch dicts. | |
| Expects columns: name, group, amount, unit, method, min, median, max. | |
| Args: | |
| file: file-like object pointing to an .xlsx workbook. | |
| Returns: | |
| list[dict]: one dict per unique batch name with keys | |
| name, group, amount, unit, methods. | |
| Raises: | |
| ValueError: if required columns are missing. | |
| """ | |
| df = pd.read_excel(file, sheet_name=0) | |
| required = {"name", "group", "amount", "unit", "method", "min", "median", "max"} | |
| if not required.issubset(df.columns): | |
| raise ValueError(f"Missing columns: {required - set(df.columns)}") | |
| batches = [] | |
| for batch_name in df["name"].unique(): | |
| df_b = df[df["name"] == batch_name] | |
| methods = { | |
| row["method"]: { | |
| "min": float(row["min"]), | |
| "median": float(row["median"]), | |
| "max": float(row["max"]), | |
| } | |
| for _, row in df_b.iterrows() | |
| } | |
| group_name = str(df_b["group"].iloc[0]) | |
| resource_to_group = get_resource_to_group() | |
| batches.append({ | |
| "name": str(batch_name), | |
| "group": group_name, | |
| "amount": float(df_b["amount"].iloc[0]), | |
| "unit": resource_unit_map.get(resource_to_group.get(group_name), "?"), | |
| "methods": methods, | |
| }) | |
| return batches | |
| def dialog_load_custom_batch(): | |
| """Dialog for importing custom resource batches from an Excel file. | |
| Reads an .xlsx file produced by to_excel_bytes, previews each batch's | |
| status (new / overwrite / conflict), and merges them into session state | |
| on confirmation. | |
| Returns: | |
| None | |
| """ | |
| st.markdown('<p class="caption">Import an Excel file previously exported from the <strong>Custom resource</strong> section.</p>', unsafe_allow_html=True) | |
| uploaded = st.file_uploader("Upload Excel file", type=["xlsx"], key="inputs_loader_custom") | |
| if uploaded is None: | |
| return | |
| try: | |
| new_batches = parse_custom_excel(uploaded) | |
| except Exception as e: | |
| st.error(f"Error reading file: {e}") | |
| return | |
| standard_names = standard_resource_names() | |
| existing_names = {b["name"] for b in st.session_state.custom_resources} | |
| previews = [] | |
| for batch in new_batches: | |
| if batch["name"] in standard_names: | |
| status = "β Conflicts with standard resource β will be skipped" | |
| elif batch["name"] in existing_names: | |
| status = "β οΈ Already exists β will be overwritten" | |
| else: | |
| status = "β New" | |
| previews.append({ | |
| "Resource name": batch["name"], | |
| "Group": batch["group"], | |
| "Amount": f"{batch["amount"]} {resource_unit_map.get(batch["group"], "")}", | |
| "Methods": len(batch["methods"]), | |
| "Status": status, | |
| }) | |
| st.dataframe(pd.DataFrame(previews), width='stretch', hide_index=True) | |
| if st.button("β©οΈ Import all batches", type="primary", width='stretch'): | |
| imported = 0 | |
| for batch in new_batches: | |
| if batch["name"] in standard_names: | |
| continue | |
| if batch["name"] in existing_names: | |
| idx = next(i for i, b in enumerate(st.session_state.custom_resources) | |
| if b["name"] == batch["name"]) | |
| st.session_state.custom_resources[idx] = batch | |
| else: | |
| st.session_state.custom_resources.append(batch) | |
| imported += 1 | |
| st.session_state.pop("latest_result", None) | |
| st.success(f"Imported {imported} batch(es).") | |
| st.rerun() | |
| def render_custom_batch_inline(global_idx, batch, cost_sliders, unit): | |
| """Render a single custom batch inside its resource-group expander.""" | |
| amount_val = float(batch["amount"]) | |
| batch["unit"] = unit | |
| with st.container(border=True): | |
| key_amount = f"custom_amount_{global_idx}" | |
| if key_amount not in st.session_state: | |
| st.session_state[key_amount] = amount_val if amount_val else None | |
| col_amount, _, _, _ = st.columns(4) | |
| with col_amount: | |
| new_amount = st.number_input( | |
| f"{batch['name']}", | |
| min_value=0.0, | |
| format="%.2f", | |
| key=key_amount, | |
| placeholder="0.0", | |
| ) | |
| new_amount_float = float(new_amount) if new_amount is not None else 0.0 | |
| if new_amount_float != amount_val: | |
| st.session_state.custom_resources[global_idx]["amount"] = new_amount_float | |
| st.session_state.pop("latest_result", None) | |
| col_min = f"Min ({unit}/MtCOβ)" | |
| col_med = f"Median ({unit}/MtCOβ)" | |
| col_max = f"Max ({unit}/MtCOβ)" | |
| rows = [ | |
| { | |
| "Method": m, | |
| col_min: float(c["min"]), | |
| col_med: float(c["median"]), | |
| col_max: float(c["max"]), | |
| } | |
| for m, c in batch["methods"].items() | |
| ] | |
| if rows: | |
| st.dataframe( | |
| pd.DataFrame(rows), | |
| width='stretch', | |
| hide_index=True, | |
| key=f"batch_editor_{global_idx}", | |
| column_config={ | |
| "Method": st.column_config.TextColumn(disabled=True), | |
| col_min: st.column_config.NumberColumn(format="%.4f", min_value=0.0), | |
| col_med: st.column_config.NumberColumn(format="%.4f", min_value=0.0), | |
| col_max: st.column_config.NumberColumn(format="%.4f", min_value=0.0), | |
| }, | |
| ) | |
| else: | |
| secondary_methods = [ | |
| m for m in cost_sliders | |
| if (m, batch["group"]) in secondary_resources | |
| ] | |
| if secondary_methods: | |
| methods_list = ", ".join(f"**{m}**" for m in secondary_methods) | |
| st.caption( | |
| f"*No CDR methods selected.*\n\n" | |
| f"This resource is available as an optional input for: {methods_list}.\n\n" | |
| f"Go to the **Resource Coefficients** tab to enable this resource for any of the methods above." | |
| ) | |
| else: | |
| st.caption("No CDR methods selected.") | |
| with st.container(key=f"container-button-{global_idx}"): | |
| if st.button("Delete", key=f"del_batch_{global_idx}", | |
| type="primary", icon=":material/delete:"): | |
| st.session_state.custom_resources.pop(global_idx) | |
| st.session_state.pop("latest_result", None) | |
| st.rerun() | |
| if st.button("Edit", key=f"edit_batch_{global_idx}", | |
| type="secondary", icon=":material/edit:"): | |
| dialog_add_custom_resource(cost_sliders, edit_index=global_idx) | |
| def panel_custom_resources_controls(cost_sliders): | |
| """Buttons to add / load / download custom resource batches (no batch display here).""" | |
| init_custom_resources() | |
| st.markdown('<p class="crra-sub-heading">β New resource</p>', unsafe_allow_html=True) | |
| st.markdown( | |
| '<p class="caption"> Add sub-batches for any resource group and manually assign them to specific CDR methods.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| batches = st.session_state.custom_resources | |
| with st.container(key="container-btn"): | |
| if st.button("New resource", type="tertiary", icon=":material/add:", key="btn-new-resource"): | |
| dialog_add_custom_resource(cost_sliders) | |
| if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load"): | |
| dialog_load_custom_batch() | |
| if batches: | |
| st.download_button( | |
| "Download new resources", | |
| data=to_excel_bytes(batches), | |
| file_name="new_resources.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| icon=":material/download:", | |
| key="download_button_save_custom", | |
| ) | |
| def render_tab(cost_sliders): | |
| """Render the Resource Inputs tab. | |
| Shows per-resource number inputs grouped by resource group, custom batch | |
| controls, an upload/download template workflow, and a summary table. Also | |
| auto-deactivates CDR methods whose primary resources are all disabled. | |
| Args: | |
| cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}. | |
| Returns: | |
| None | |
| """ | |
| if "resource_caps" not in st.session_state: | |
| st.session_state.resource_caps = {} | |
| for r in resource_order: | |
| if r not in st.session_state.resource_caps: | |
| st.session_state.resource_caps[r] = 0.0 | |
| init_custom_resources() | |
| st.markdown('<h1 class="crra-heading">Resource inputs</h1>', unsafe_allow_html=True) | |
| show_bar() | |
| st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True) | |
| st.markdown( | |
| '<p class="text_with_border">This section lets you define the <strong>total available ' | |
| 'quantities of each resource</strong> for the optimization model.<br>' | |
| 'Availability is per <strong>year</strong> (rate) for the specific year the portfolio is being estimated, for example 2030, or 2050</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| with st.container(key="warning_resources"): | |
| st.warning( | |
| "Resources with zero availability will block portfolio feasibility if required by active methods.", | |
| icon=":material/warning:", | |
| ) | |
| st.markdown('<p class="crra-sub-heading">Controls</p>', unsafe_allow_html=True) | |
| if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"): | |
| for r in st.session_state.resource_caps: | |
| st.session_state.resource_caps[r] = 0.0 | |
| st.session_state.custom_resources = [] | |
| st.session_state.enabled_methods = {} | |
| st.success("All resource values reset to 0.") | |
| st.rerun() | |
| st.markdown('<p class="crra-sub-sub-heading">Import resource values</p>', unsafe_allow_html=True) | |
| col_template, col_upload = st.columns([1, 1]) | |
| with col_template: | |
| st.markdown("<div class='caption'><p>Use the template below to fill in your resource " | |
| "availability values and import them directly into the tool.</p></div>", | |
| unsafe_allow_html=True) | |
| with open("data/resource_template.xlsx", "rb") as f: | |
| st.download_button( | |
| label="Download Excel Template", | |
| data=f, | |
| file_name="resource_template.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| icon=":material/download:", | |
| key="download_button_resource_template" | |
| ) | |
| with col_upload: | |
| uploaded_file = st.file_uploader( | |
| "Upload your Excel file", type="xlsx", | |
| key=f"resource_uploader_{st.session_state.get('resource_uploader_key', 0)}" | |
| ) | |
| if uploaded_file is not None and uploaded_file.name != st.session_state.get("resource_last_loaded_file"): | |
| try: | |
| df = pd.read_excel(uploaded_file) | |
| required_cols = {"Resource", "Available Amount"} | |
| if not required_cols.issubset(df.columns): | |
| raise ValueError(f"Missing columns: {required_cols - set(df.columns)}") | |
| for r in st.session_state.resource_caps: | |
| st.session_state.resource_caps[r] = 0.0 | |
| st.session_state[f"cap_{r}"] = 0.0 | |
| for _, row in df.iterrows(): | |
| r = row["Resource"] | |
| val = row["Available Amount"] | |
| if r not in st.session_state.resource_caps: | |
| continue | |
| try: | |
| val = 0.0 if pd.isna(val) else float(val) | |
| except (ValueError, TypeError): | |
| val = 0.0 | |
| st.session_state.resource_caps[r] = val | |
| st.session_state[f"cap_{r}"] = val | |
| st.session_state["resource_last_loaded_file"] = uploaded_file.name | |
| st.success("Resource values imported successfully.", icon="β ") | |
| for key in ["excel_loaded_file", "scenario_name_suggestion", | |
| "scenario_loaded_file", "input_file_name_suggestion", "latest_result"]: | |
| st.session_state.pop(key, None) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error importing resource values: {e}") | |
| elif st.session_state.get("resource_last_loaded_file"): | |
| st.success(f"Loaded: **{st.session_state['resource_last_loaded_file']}**", icon="β ") | |
| # Standard resource inputs | |
| st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True) | |
| # Build a lookup: for each CDR method, which resources are "primary" (mandatory)? | |
| # Secondary resources are optional add-ons β a method can still run without them. | |
| # A method can only run in the optimisation if at least one primary resource is available. | |
| method_to_primary_resources = {} | |
| for cdr_method, resource_coeffs in cost_sliders.items(): | |
| for name in resource_coeffs: | |
| r_clean = strip_unit_suffix(name) | |
| if (cdr_method, r_clean) not in secondary_resources: | |
| method_to_primary_resources.setdefault(cdr_method, []).append(r_clean) | |
| for r in method_to_primary_resources: | |
| pass # no-op, kept for clarity of intent below | |
| resource_to_group = get_resource_to_group() | |
| for group, rlist in resource_groups.items(): | |
| unit = resource_unit_map.get(group, "") | |
| with st.expander(f"**{group}** ({unit}/yr)", expanded=True): | |
| display_rlist = [r for r in rlist if r not in hidden_resource_names] | |
| if not display_rlist: | |
| st.caption( | |
| "No standard resource to display for this group. " | |
| "Add a specific resource below to include it in the optimization." | |
| ) | |
| cols = st.columns(4) | |
| for i, r in enumerate(display_rlist): | |
| tooltip = resource_tooltips.get(r, None) | |
| name_clean = r.split(" (")[0] | |
| with cols[i % 4]: | |
| render_resource_input(name_clean, r, tooltip=tooltip) | |
| related_methods = {} | |
| related_by_group = {} | |
| for cdr_method, resource in cost_sliders.items(): | |
| for name, coeff in resource.items(): | |
| if r == strip_unit_suffix(name): | |
| related_methods[cdr_method] = coeff | |
| for m in related_methods: | |
| related_by_group.setdefault(get_method_group(m), []).append(m) | |
| related_method_names = list(related_methods.keys()) | |
| secondary_for_r = [m for m in related_method_names if (m, r) in secondary_resources] | |
| primary_for_r = [m for m in related_method_names if (m, r) not in secondary_resources] | |
| if r not in st.session_state.enabled_methods: | |
| st.session_state.enabled_methods[r] = {} | |
| for m in related_method_names: | |
| st.session_state.enabled_methods[r].setdefault(m, True) | |
| popover_key_prefix = f"pop_{r}" | |
| with st.popover( | |
| f":material/tune: Related CDR Methods", | |
| width='stretch', | |
| ): | |
| for method_group, methods in related_by_group.items(): | |
| primary_in_group = [m for m in methods if m in primary_for_r] | |
| if not primary_in_group: | |
| continue | |
| st.markdown(f"*{method_group}*") | |
| for m in primary_in_group: | |
| widget_key = f"{popover_key_prefix}_{m}" | |
| if widget_key not in st.session_state: | |
| st.session_state[widget_key] = st.session_state.enabled_methods[r][m] | |
| checked = st.checkbox(m, key=widget_key) | |
| st.session_state.enabled_methods[r][m] = checked | |
| if secondary_for_r: | |
| st.caption( | |
| "The following methods use this resource as secondary and may be assigned later in the **Resource Coefficients** tab:\n\n" | |
| + "\n".join(f"- {m}" for m in secondary_for_r) | |
| ) | |
| for m in secondary_for_r: | |
| st.session_state.enabled_methods[r][m] = True | |
| # Custom batches for this group | |
| group_unit = resource_unit_map.get(group, "") | |
| group_batches = [ | |
| (idx, b) for idx, b in enumerate(st.session_state.get("custom_resources", [])) | |
| if resource_to_group.get(b["group"]) == group | |
| ] | |
| for global_idx, batch in group_batches: | |
| render_custom_batch_inline(global_idx, batch, cost_sliders, group_unit) | |
| _, col_button = st.columns([3,1]) | |
| with col_button: | |
| if st.button( | |
| "Add resource", key=f"simple-btn-resource-{group}", | |
| type="tertiary", icon=":material/add:", width='stretch' | |
| ): | |
| dialog_add_custom_resource(cost_sliders, preset_resources=rlist) | |
| # ββ AUTO-DISABLE LOGIC ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # A CDR method cannot run in the optimisation if it has no usable resources. | |
| # This block detects that situation automatically and deactivates the method | |
| # in the Method Constraints tab β so the optimisation never fails silently. | |
| # | |
| # The logic runs in three steps every time this tab renders: | |
| # 1. Count how many active primary resources each method still has. | |
| # 2. Re-enable any method that was auto-disabled and now has resources again | |
| # (e.g. the user re-entered a value). Methods the user manually disabled | |
| # are stored separately and are NOT touched here. | |
| # 3. Disable any method whose resource count just dropped to zero, and show | |
| # a notification so the user knows what changed. | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Step 1 β count active primary resources per method (secondary resources are | |
| # optional extras; they do not determine whether a method can run). | |
| method_active_resource_count = {} | |
| for r, methods in st.session_state.enabled_methods.items(): | |
| for m, ok in methods.items(): | |
| if (m, r) in secondary_resources: | |
| continue | |
| method_active_resource_count.setdefault(m, 0) | |
| if ok: | |
| method_active_resource_count[m] += 1 | |
| # Custom batches also count as active resources for the methods they are | |
| # assigned to, even when the standard reference resource is toggled off. | |
| for batch in st.session_state.get("custom_resources", []): | |
| if float(batch.get("amount") or 0) <= 0: | |
| continue | |
| for m_name in batch.get("methods", {}).keys(): | |
| method_active_resource_count.setdefault(m_name, 0) | |
| method_active_resource_count[m_name] += 1 | |
| methods_with_no_active_resource = [ | |
| m for m in method_to_primary_resources | |
| if method_active_resource_count.get(m, 0) == 0 | |
| ] | |
| # Step 2 β re-enable methods that the tool previously auto-disabled but that | |
| # now have at least one active resource (user restored a value or added a batch). | |
| for m in list(st.session_state.auto_disabled_methods): | |
| if method_active_resource_count.get(m, 0) > 0: | |
| if m in st.session_state.constraints: | |
| st.session_state.constraints[m]["active"] = True | |
| st.session_state.constraints[m]["cap_type"] = "percent" | |
| st.session_state.constraints[m]["cap_value"] = 100 | |
| st.session_state[f"toggle_{m}"] = True | |
| st.session_state[f"slider_{m}"] = 100 | |
| st.session_state.auto_disabled_methods.discard(m) | |
| st.session_state.setdefault("just_activated_methods", set()).add(m) | |
| # Step 3 β disable methods that now have zero active primary resources. | |
| # We record which methods the tool disabled (auto_disabled_methods) so that | |
| # step 2 can safely re-enable them later without touching user-chosen settings. | |
| newly_disabled = [] | |
| for m in methods_with_no_active_resource: | |
| # Always track in auto_disabled_methods so Step 2 can re-enable later, | |
| # even if the method was already inactive (e.g. initialised as False in app.py). | |
| st.session_state.auto_disabled_methods.add(m) | |
| if st.session_state.constraints.get(m, {}).get("active", True): | |
| st.session_state.constraints.setdefault(m, {"active": True, "cap_type": "percent", "cap_value": 100}) | |
| st.session_state.constraints[m]["active"] = False | |
| newly_disabled.append(m) | |
| toggle_key = f"toggle_{m}" | |
| if toggle_key in st.session_state: | |
| st.session_state[toggle_key] = False | |
| panel_custom_resources_controls(cost_sliders) | |
| st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True) | |
| resource_to_unit = get_resource_to_unit() | |
| df_caps = pd.DataFrame.from_dict( | |
| {r: st.session_state.resource_caps[r] for r in resource_order | |
| if st.session_state.resource_caps.get(r, 0.0) > 0}, | |
| orient="index", columns=["Available Amount"], | |
| ).reindex(resource_order).dropna() | |
| df_caps["Available Amount"] = [ | |
| f"{value:,.2f} {resource_to_unit.get(resource, '?')}" | |
| for resource, value in df_caps["Available Amount"].items() | |
| ] | |
| custom_rows = [] | |
| for batch in st.session_state.get("custom_resources", []): | |
| group = resource_to_group.get(batch["group"]) | |
| unit = resource_unit_map.get(group, "?") | |
| custom_rows.append({ | |
| "Resource": batch["name"], | |
| "Available Amount": f"{float(batch['amount']):,.2f} {unit}", | |
| }) | |
| if not df_caps.empty or custom_rows: | |
| dfs = [] | |
| if not df_caps.empty: | |
| df_std = df_caps.copy() | |
| df_std["Type"] = "Standard" | |
| dfs.append(df_std) | |
| if custom_rows: | |
| df_custom = pd.DataFrame(custom_rows).set_index("Resource") | |
| df_custom["Type"] = "New" | |
| dfs.append(df_custom) | |
| df_all = pd.concat(dfs)[["Available Amount", "Type"]].reset_index().rename(columns={"index": "Resource"}) | |
| num_rows = len(df_all) | |
| st.dataframe( | |
| df_all, | |
| width='stretch', | |
| hide_index=True, | |
| height=(num_rows * 36 + 36), | |
| column_config={ | |
| "Resource": st.column_config.TextColumn("Resource", width="large"), | |
| "Available Amount": st.column_config.TextColumn("Available Amount"), | |
| "Type": st.column_config.TextColumn("Type"), | |
| }, | |
| row_height=36 | |
| ) | |
| else: | |
| st.error("No resources defined yet. Set availability above to populate the summary table.") |