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, section_intros) | |
| from utils.data_utils import (strip_unit_suffix, get_resource_to_group, get_resource_to_unit, | |
| get_method_group, standard_resource_names, to_excel_bytes, parse_custom_excel, | |
| resource_caps_to_excel_bytes) | |
| from utils.state_utils import init_custom_resources | |
| from utils.ui_helpers import show_bar, render_resource_input | |
| from tabs.tab2_constraints import refresh_active_methods | |
| 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 | |
| """ | |
| # Use edit_index as a key suffix so each edit session gets fresh widgets. | |
| # Streamlit ignores value= when a key already exists in session_state, so | |
| # suffixing with eix guarantees unique keys per resource and avoids stale data. | |
| eix = edit_index if edit_index is not None else "new" | |
| 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. | |
| 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=f"label-input-name-{eix}" | |
| ) | |
| if preset_resources: | |
| # Already scoped to a specific resource list (inline "Add resource" button) | |
| preset_idx = preset_resources.index(existing["group"]) if existing.get("group") in preset_resources else 0 | |
| with col_group: | |
| batch_group = st.selectbox( | |
| "Resource group *", | |
| options=preset_resources, | |
| index=preset_idx, | |
| help="Determines the physical unit of this resource.", | |
| disabled=len(preset_resources) == 1, | |
| key=f"label-input-group-{eix}" | |
| ) | |
| else: | |
| # Two-level selection: physical group → specific resource | |
| phys_group_options = list(resource_groups.keys()) | |
| existing_phys = resource_to_group.get(existing.get("group", ""), phys_group_options[0]) | |
| default_phys_idx = phys_group_options.index(existing_phys) if existing_phys in phys_group_options else 0 | |
| with col_group: | |
| selected_phys = st.selectbox( | |
| "Resource group *", | |
| options=phys_group_options, | |
| index=default_phys_idx, | |
| help="Determines the physical category of this resource.", | |
| key=f"label-input-phys-group-{eix}" | |
| ) | |
| sub_resources = resource_groups[selected_phys] | |
| if len(sub_resources) > 1: | |
| existing_sub = existing.get("group", "") | |
| default_sub_idx = sub_resources.index(existing_sub) if existing_sub in sub_resources else 0 | |
| batch_group = st.selectbox( | |
| "Resource sub-type *", | |
| options=sub_resources, | |
| index=default_sub_idx, | |
| help="Specific resource type within this group.", | |
| key=f"label-input-subgroup-{eix}" | |
| ) | |
| else: | |
| batch_group = sub_resources[0] | |
| 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="%.10g", | |
| 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=f"label-input-amount-{eix}", | |
| 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", {}) | |
| enabled = st.checkbox( | |
| f"{m}", | |
| value=already_selected, | |
| key=f"_dlg_existing_enable_{eix}_{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.", icon=":material/warning:") | |
| 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=max(float(0.0), float(existing_coeff["min"])), | |
| key=f"_dlg_existing_min_{eix}_{m}", | |
| ) | |
| with col_med: | |
| e_med = st.number_input( | |
| f"Median ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| format="%.10g", | |
| value=max(float(0.0), float(existing_coeff["median"])), | |
| key=f"_dlg_existing_med_{eix}_{m}", | |
| ) | |
| with col_max: | |
| e_max = st.number_input( | |
| f"Max ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| format="%.10g", | |
| value=max(float(0.0), float(existing_coeff["max"])), | |
| key=f"_dlg_existing_max_{eix}_{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: | |
| already_selected = m in existing.get("methods", {}) | |
| enabled = st.checkbox( | |
| f"**{m}**", | |
| value=already_selected, | |
| key=f"_dlg_new_chk_{eix}_{m}", | |
| ) | |
| if enabled: | |
| existing_coeff = existing.get("methods", {}).get(m, {}) | |
| 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=float(existing_coeff["min"]) if existing_coeff.get("min") is not None else None, | |
| format="%.10g", | |
| key=f"_dlg_new_min_{eix}_{m}", | |
| placeholder="0.0000", | |
| ) | |
| with col_med: | |
| v_med = st.number_input( | |
| f"Median ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| value=float(existing_coeff["median"]) if existing_coeff.get("median") is not None else None, | |
| format="%.10g", | |
| key=f"_dlg_new_med_{eix}_{m}", | |
| placeholder="0.0000", | |
| ) | |
| with col_max: | |
| v_max = st.number_input( | |
| f"Max ({unit}/{CO2_UNIT})", | |
| min_value=0.0, | |
| value=float(existing_coeff["max"]) if existing_coeff.get("max") is not None else None, | |
| format="%.10g", | |
| key=f"_dlg_new_max_{eix}_{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=f"btn-reset-cancel-{eix}"): | |
| 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().lower() in {n.lower() for n 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=f"btn-new-add-{eix}"): | |
| 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: | |
| # Clean up stale costs[method][old_name] entries left behind by | |
| # this edit — otherwise tab4's missing-resource check wrongly | |
| # deactivates that method on the next optimisation run. Two | |
| # triggers: (a) a method that was previously assigned is no | |
| # longer checked, or (b) the batch itself was renamed, which | |
| # orphans the old name even for methods still checked (the new | |
| # name gets a fresh entry injected at run time, but the old | |
| # name's entry is never removed on its own). | |
| old_name = existing.get("name", "") | |
| new_name = batch_name.strip() | |
| renamed = bool(old_name) and old_name != new_name | |
| for method_name in existing.get("methods", {}): | |
| if renamed or method_name not in method_configs: | |
| st.session_state.costs.get(method_name, {}).pop(old_name, 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) | |
| # Push the new median into costs for any (method, coefficient) | |
| # pair that actually changed in this edit — tab3's own init only | |
| # sets a value the first time a key appears (`if r not in | |
| # costs[m]`), so without this a coefficient edit made here would | |
| # never overwrite the stale value already there. Only touch | |
| # pairs whose median actually changed (or that are brand new) so | |
| # a deliberate Tab 3 slider adjustment isn't silently reset just | |
| # because the user edited something unrelated (e.g. the amount). | |
| old_methods = existing.get("methods", {}) | |
| for method_name, coeff in method_configs.items(): | |
| old_coeff = old_methods.get(method_name, {}) | |
| if old_coeff.get("median") != coeff["median"]: | |
| st.session_state.costs.setdefault(method_name, {})[batch["name"]] = coeff["median"] | |
| # If min/max changed, tab3's current scenario value (used to | |
| # seed its slider/manual widgets, keyed only by method+resource | |
| # name so it persists across this edit) may now fall outside | |
| # the new range — st.slider raises if its stored value is out | |
| # of [min_value, max_value]. Clamp it into the new bounds | |
| # (rather than resetting to the median) so the user's chosen | |
| # operating point is kept as close as possible, then clear the | |
| # widget keys so tab3 re-initialises from this clamped value. | |
| if old_coeff.get("min") != coeff["min"] or old_coeff.get("max") != coeff["max"]: | |
| current = st.session_state.costs.get(method_name, {}).get(batch["name"]) | |
| if current is not None: | |
| clamped = max(coeff["min"], min(coeff["max"], current)) | |
| st.session_state.costs.setdefault(method_name, {})[batch["name"]] = clamped | |
| key_suffix = f"{method_name}_{batch['name']}".replace(" ", "_") | |
| for prefix in ("slider_", "manual_", "toggle_"): | |
| st.session_state.pop(f"{prefix}{key_suffix}", None) | |
| st.session_state.pop("latest_result", None) | |
| st.rerun() | |
| 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"]) | |
| # Purge costs[method][batch_name] for methods dropped by the | |
| # re-imported version — harmless today (build_lp_indices gates | |
| # on the batch's own primary_methods) but avoids stale data | |
| # lingering in session state until the next Tab 3 visit. | |
| old_methods = set(st.session_state.custom_resources[idx].get("methods", {})) | |
| for method_name in old_methods - set(batch.get("methods", {})): | |
| st.session_state.costs.get(method_name, {}).pop(batch["name"], None) | |
| 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 dialog_load_standard_resources(): | |
| """Dialog for importing standard resource availabilities from an Excel file. | |
| Reads an .xlsx file with "Resource" and "Available Amount" columns (the | |
| same format as the downloadable template / current-values export) and | |
| overwrites st.session_state.resource_caps on confirmation. | |
| Returns: | |
| None | |
| """ | |
| st.markdown( | |
| '<p class="caption">Upload an Excel file: either the blank template or a previous export of your current values.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| uploaded = st.file_uploader("Upload Excel file", type=["xlsx"], key="resource_loader_dialog") | |
| if uploaded is None: | |
| return | |
| try: | |
| df = pd.read_excel(uploaded) | |
| required_cols = {"Resource", "Available Amount"} | |
| if not required_cols.issubset(df.columns): | |
| raise ValueError(f"Missing columns: {required_cols - set(df.columns)}") | |
| except Exception as e: | |
| st.error(f"Error reading file: {e}") | |
| return | |
| preview_rows = [ | |
| {"Resource": row["Resource"], "Available Amount": row["Available Amount"]} | |
| for _, row in df.iterrows() | |
| if row["Resource"] in st.session_state.resource_caps | |
| ] | |
| st.dataframe(pd.DataFrame(preview_rows), width='stretch', hide_index=True) | |
| if st.button("Import values", type="primary", width='stretch', icon=":material/check:"): | |
| 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"] | |
| if r not in st.session_state.resource_caps: | |
| continue | |
| val = row["Available Amount"] | |
| 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.name | |
| 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.success("Resource values imported successfully.", icon=":material/check:") | |
| 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="%.10g", | |
| 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="%.10g", min_value=0.0), | |
| col_med: st.column_config.NumberColumn(format="%.10g", min_value=0.0), | |
| col_max: st.column_config.NumberColumn(format="%.10g", 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:"): | |
| deleted = st.session_state.custom_resources.pop(global_idx) | |
| # Clean up any coefficient this batch left in st.session_state.costs | |
| # for the methods it was assigned to. Without this, a stale | |
| # costs[method][batch_name] entry survives the deletion, and tab4's | |
| # "missing resource" check (which only looks at custom_resources, | |
| # not costs) wrongly deactivates that method on the next run. | |
| for method_name in deleted.get("methods", {}): | |
| st.session_state.costs.get(method_name, {}).pop(deleted["name"], None) | |
| st.session_state.pop("latest_result", None) | |
| st.rerun() | |
| if st.button("Edit", key=f"edit_batch_{global_idx}", | |
| type="secondary", icon=":material/edit:"): | |
| _b = st.session_state.custom_resources[global_idx] | |
| _phys = get_resource_to_group().get(_b.get("group", "")) | |
| _preset = resource_groups.get(_phys) if _phys else None | |
| dialog_add_custom_resource(cost_sliders, edit_index=global_idx, preset_resources=_preset) | |
| 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_related_methods_popover(r, cost_sliders, secondary_resources): | |
| """Render the 'Related CDR Methods' popover for a single resource. | |
| Builds the method-to-group mapping, initialises enabled_methods in session | |
| state, and renders a popover with per-method checkboxes (primary only) and | |
| a caption listing secondary methods. | |
| Args: | |
| r (str): canonical resource name. | |
| cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}. | |
| secondary_resources (set[tuple[str, str]]): set of (method, resource) pairs | |
| where the resource is optional for that method. | |
| Returns: | |
| None | |
| """ | |
| 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(":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 | |
| def render_resource_summary(): | |
| """Render the 'Summary of available resources' section. | |
| Builds a combined dataframe of standard and custom resource availability | |
| and displays it as a Streamlit dataframe. | |
| Returns: | |
| None | |
| """ | |
| st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True) | |
| resource_to_unit = get_resource_to_unit() | |
| resource_to_group = get_resource_to_group() | |
| 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.warning("No resources defined yet. Set availability above to populate the summary table.", icon=":material/warning:") | |
| 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 | |
| """ | |
| # resource_caps is already fully populated (every resource_order key set) | |
| # by app.py's shared init, which runs before any tab renders. | |
| 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( | |
| f'<p class="text_with_border">{section_intros.get("tab1_inputs", "")}</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 | |
| # Purge costs[method][batch_name] entries left by custom resources before | |
| # clearing them below — otherwise tab4's missing-resource check wrongly | |
| # deactivates any method that relied on a batch no longer in existence. | |
| for batch in st.session_state.custom_resources: | |
| for method_name in batch.get("methods", {}): | |
| st.session_state.costs.get(method_name, {}).pop(batch["name"], None) | |
| 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) | |
| st.markdown( | |
| "<p class='caption'>You can use the template below to fill in your resource availability values " | |
| "for a given year and import them directly into the tool." | |
| "<br>" | |
| "You can also re-import a file previously downloaded from this section.</p>", | |
| unsafe_allow_html=True, | |
| ) | |
| with st.container(key="container-btn-resources"): | |
| 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" | |
| ) | |
| if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load-resources"): | |
| dialog_load_standard_resources() | |
| if st.session_state.get("resource_last_loaded_file"): | |
| st.success(f"Loaded: **{st.session_state['resource_last_loaded_file']}**", icon=":material/check:") | |
| # Standard resource inputs | |
| st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True) | |
| 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 optimisation." | |
| ) | |
| 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) | |
| render_related_methods_popover(r, cost_sliders, secondary_resources) | |
| # 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"btn-add-resource-{group}", | |
| type="secondary", icon=":material/add:", width='stretch' | |
| ): | |
| dialog_add_custom_resource(cost_sliders, preset_resources=rlist) | |
| # Refresh method activation now that this render's resource inputs and | |
| # "Related CDR Methods" toggles are final — reuses the same real-quantity | |
| # check as app.py's shared init (which runs too early to see toggles the | |
| # user just changed on this exact render) and Tab 2's own pre-render sync. | |
| cost_sliders_norm = { | |
| m: {strip_unit_suffix(name): conf for name, conf in resources.items()} | |
| for m, resources in cost_sliders.items() | |
| } | |
| refresh_active_methods(cost_sliders_norm) | |
| # Rendered after all resource inputs above (which write into | |
| # resource_caps) so the export always reflects this render's latest | |
| # values — placing it earlier would download the previous rerun's values, | |
| # since Streamlit executes top-to-bottom and the inputs update the dict | |
| # further down in the script. | |
| if any(v > 0 for v in st.session_state.resource_caps.values()): | |
| st.download_button( | |
| label="Download current resources", | |
| data=resource_caps_to_excel_bytes(st.session_state.resource_caps), | |
| file_name="current_resources.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| icon=":material/download:", | |
| key="download_button_current_resources", | |
| ) | |
| panel_custom_resources_controls(cost_sliders) | |
| render_resource_summary() | |
| if st.button("Method constraints →", key="btn-nav-next-inputs", type="primary", help="Configure which CDR methods are active and set their constraints."): | |
| st.session_state["_navigate_to"] = "Method constraints" | |
| st.rerun() |