CRRA_Optimization_Tool_V2 / tabs /tab1_inputs.py
CarlyneB's picture
[FIX] Hide Other resources in tab1, fix unit label (yr instead of year)
78c94c6
Raw
History Blame
39.1 kB
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'<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
@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('<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.")