CRRA_Optimization_Tool_V2 / tabs /tab1_inputs.py
CarlyneB's picture
[UX] British spelling: rename optimization → optimisation throughout code (conflict resolve)
7a80c0b
Raw
History Blame Contribute Delete
40.2 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, 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
@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
"""
# 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()
@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"])
# 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()
@st.dialog("Load standard resource values", width="large")
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()