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 @st.dialog("Add a new resource", width="large") 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'
Adding a new resource batch in the {physical_group} group.
', unsafe_allow_html=True, ) st.markdown('1 — Name & group
', 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'Unit for this group: {unit}/yr
', unsafe_allow_html=True) st.markdown('2 — Available quantity
', 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('3 — Existing CDR methods for this resource
', 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('No primary CDR methods use this resource directly.
', 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('4 — Add additional CDR methods
', unsafe_allow_html=True) st.markdown(f'Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.
', 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 @st.dialog("Load custom resource batches", width="large") 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('Import an Excel file previously exported from the Custom resource section.
', 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('➕ New resource
', unsafe_allow_html=True) st.markdown( 'Add sub-batches for any resource group and manually assign them to specific CDR methods.
', 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('OBJECTIVE
', unsafe_allow_html=True) st.markdown( 'This section lets you define the total available '
'quantities of each resource for the optimization model.
'
'Availability is per year (rate) for the specific year the portfolio is being estimated, for example 2030, or 2050
Controls
', 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('Import resource values
', unsafe_allow_html=True) col_template, col_upload = st.columns([1, 1]) with col_template: st.markdown("Use the template below to fill in your resource " "availability values and import them directly into the tool.
Set available resources for one year
', 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('Summary of available resources
', 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.")