Spaces:
Running
Running
[REFACTOR] utils: centralize helpers — add state_utils, move render_resource_input to ui_helpers, consolidate data_utils
Browse files- utils/data_utils.py +221 -32
- utils/state_utils.py +96 -0
- utils/ui_helpers.py +32 -0
utils/data_utils.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
-
import io
|
| 2 |
import json
|
| 3 |
import re
|
|
|
|
|
|
|
| 4 |
import pandas as pd
|
| 5 |
import streamlit as st
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
def get_resource_to_group() -> dict[str, str]:
|
| 11 |
"""Map each resource name to its resource group.
|
|
@@ -45,36 +47,14 @@ def load_slider_data():
|
|
| 45 |
data = json.load(f)
|
| 46 |
return data
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
"""
|
| 50 |
-
|
| 51 |
-
Writes the entered value into st.session_state.resource_caps[r].
|
| 52 |
-
|
| 53 |
-
Args:
|
| 54 |
-
name_clean (str): display label (resource name without unit suffix).
|
| 55 |
-
r (str): canonical resource name used as the session state key.
|
| 56 |
-
tooltip (str | None): optional help text shown on hover.
|
| 57 |
|
| 58 |
Returns:
|
| 59 |
-
|
| 60 |
"""
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
if widget_key not in st.session_state:
|
| 64 |
-
existing = st.session_state.resource_caps.get(r, 0.0)
|
| 65 |
-
st.session_state[widget_key] = existing if existing else None
|
| 66 |
-
|
| 67 |
-
cap = st.number_input(
|
| 68 |
-
label=name_clean,
|
| 69 |
-
min_value=0.0,
|
| 70 |
-
format="%.2f",
|
| 71 |
-
value=None,
|
| 72 |
-
placeholder="0.00",
|
| 73 |
-
key=widget_key,
|
| 74 |
-
help=tooltip
|
| 75 |
-
)
|
| 76 |
|
| 77 |
-
st.session_state.resource_caps[r] = cap if cap is not None else 0.0
|
| 78 |
|
| 79 |
def is_valid_scenario(scenario):
|
| 80 |
"""Check that a scenario dict has the minimum required keys to be loaded.
|
|
@@ -237,7 +217,6 @@ def neutralize_zero_coefficient_methods(method_costs: dict, method_constraints:
|
|
| 237 |
|
| 238 |
# ---------------------------------------------------------------------------
|
| 239 |
# "Other" method expansion helpers
|
| 240 |
-
# ---------------------------------------------------------------------------
|
| 241 |
|
| 242 |
def is_other_method(method_name: str) -> bool:
|
| 243 |
"""True when the method name contains 'other' (case-insensitive).
|
|
@@ -261,4 +240,214 @@ def get_other_method_variants(method_name: str, custom_resources: list) -> list:
|
|
| 261 |
|
| 262 |
def make_variant_name(method_name: str, batch_name: str) -> str:
|
| 263 |
"""Build the display/LP name for a method variant: '<Method>: <Batch>'."""
|
| 264 |
-
return f"{method_name}: {batch_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
import numpy as np
|
| 5 |
import pandas as pd
|
| 6 |
import streamlit as st
|
| 7 |
+
from config.config import (
|
| 8 |
+
resource_unit_map, resource_order, method_order,
|
| 9 |
+
resource_groups, method_groups, secondary_resources, CO2_UNIT,
|
| 10 |
+
)
|
| 11 |
|
| 12 |
def get_resource_to_group() -> dict[str, str]:
|
| 13 |
"""Map each resource name to its resource group.
|
|
|
|
| 47 |
data = json.load(f)
|
| 48 |
return data
|
| 49 |
|
| 50 |
+
def default_constraint() -> dict:
|
| 51 |
+
"""Return the default constraint dict for a CDR method (active, 100% cap).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
Returns:
|
| 54 |
+
dict: {"active": True, "cap_type": "percent", "cap_value": 100}.
|
| 55 |
"""
|
| 56 |
+
return {"active": True, "cap_type": "percent", "cap_value": int(100)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
|
|
|
| 58 |
|
| 59 |
def is_valid_scenario(scenario):
|
| 60 |
"""Check that a scenario dict has the minimum required keys to be loaded.
|
|
|
|
| 217 |
|
| 218 |
# ---------------------------------------------------------------------------
|
| 219 |
# "Other" method expansion helpers
|
|
|
|
| 220 |
|
| 221 |
def is_other_method(method_name: str) -> bool:
|
| 222 |
"""True when the method name contains 'other' (case-insensitive).
|
|
|
|
| 240 |
|
| 241 |
def make_variant_name(method_name: str, batch_name: str) -> str:
|
| 242 |
"""Build the display/LP name for a method variant: '<Method>: <Batch>'."""
|
| 243 |
+
return f"{method_name}: {batch_name}"
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
# Resource / Excel helpers (moved from tab1)
|
| 248 |
+
|
| 249 |
+
def standard_resource_names() -> set:
|
| 250 |
+
"""Return the flat set of all standard resource names defined in resource_groups."""
|
| 251 |
+
return {r for rlist in resource_groups.values() for r in rlist}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def to_excel_bytes(batches: list) -> bytes:
|
| 255 |
+
"""Serialise all custom resource batches to an Excel file (one row per batch × method).
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
batches (list): list of custom batch dicts with keys name, group, amount, unit, methods.
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
bytes: raw bytes of the .xlsx workbook.
|
| 262 |
+
"""
|
| 263 |
+
rows = []
|
| 264 |
+
for batch in batches:
|
| 265 |
+
for m, coefs in batch["methods"].items():
|
| 266 |
+
rows.append({
|
| 267 |
+
"name": batch["name"],
|
| 268 |
+
"group": batch["group"],
|
| 269 |
+
"amount": float(batch["amount"]),
|
| 270 |
+
"unit": batch["unit"],
|
| 271 |
+
"method": m,
|
| 272 |
+
"min": float(coefs["min"]),
|
| 273 |
+
"median": float(coefs["median"]),
|
| 274 |
+
"max": float(coefs["max"]),
|
| 275 |
+
})
|
| 276 |
+
excel_buffer = BytesIO()
|
| 277 |
+
with pd.ExcelWriter(excel_buffer, engine="openpyxl") as writer:
|
| 278 |
+
pd.DataFrame(rows).to_excel(writer, index=False, sheet_name="custom_resources")
|
| 279 |
+
return excel_buffer.getvalue()
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def parse_custom_excel(file) -> list:
|
| 283 |
+
"""Parse a custom resource Excel file into a list of batch dicts.
|
| 284 |
+
|
| 285 |
+
Expects columns: name, group, amount, unit, method, min, median, max.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
file: file-like object pointing to an .xlsx workbook.
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
list[dict]: one dict per unique batch name with keys name, group, amount, unit, methods.
|
| 292 |
+
|
| 293 |
+
Raises:
|
| 294 |
+
ValueError: if required columns are missing.
|
| 295 |
+
"""
|
| 296 |
+
df = pd.read_excel(file, sheet_name=0)
|
| 297 |
+
required = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
|
| 298 |
+
if not required.issubset(df.columns):
|
| 299 |
+
raise ValueError(f"Missing columns: {required - set(df.columns)}")
|
| 300 |
+
batches = []
|
| 301 |
+
for batch_name in df["name"].unique():
|
| 302 |
+
df_b = df[df["name"] == batch_name]
|
| 303 |
+
methods = {
|
| 304 |
+
row["method"]: {
|
| 305 |
+
"min": float(row["min"]),
|
| 306 |
+
"median": float(row["median"]),
|
| 307 |
+
"max": float(row["max"]),
|
| 308 |
+
}
|
| 309 |
+
for _, row in df_b.iterrows()
|
| 310 |
+
}
|
| 311 |
+
group_name = str(df_b["group"].iloc[0])
|
| 312 |
+
resource_to_group = get_resource_to_group()
|
| 313 |
+
batches.append({
|
| 314 |
+
"name": str(batch_name),
|
| 315 |
+
"group": group_name,
|
| 316 |
+
"amount": float(df_b["amount"].iloc[0]),
|
| 317 |
+
"unit": resource_unit_map.get(resource_to_group.get(group_name), "?"),
|
| 318 |
+
"methods": methods,
|
| 319 |
+
})
|
| 320 |
+
return batches
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ---------------------------------------------------------------------------
|
| 324 |
+
# Coefficient / slider helpers (moved from tab3)
|
| 325 |
+
|
| 326 |
+
def normalize_costs(costs_dict: dict) -> dict:
|
| 327 |
+
"""Strip unit suffixes from resource keys for legacy scenario compatibility."""
|
| 328 |
+
return {strip_unit_suffix(r): v for r, v in costs_dict.items()}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def get_slider_params(conf: dict) -> tuple:
|
| 332 |
+
"""Derive slider step size and display format from a coefficient config dict.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
conf (dict): slider config with keys "min", "median", "max".
|
| 336 |
+
|
| 337 |
+
Returns:
|
| 338 |
+
tuple[float, str]: (step, fmt).
|
| 339 |
+
"""
|
| 340 |
+
min_coeff = conf["min"]
|
| 341 |
+
coeff_range = conf["max"] - min_coeff
|
| 342 |
+
|
| 343 |
+
if min_coeff < 1e-10:
|
| 344 |
+
fmt = "%.11f"
|
| 345 |
+
elif min_coeff < 1e-08:
|
| 346 |
+
fmt = "%.9f"
|
| 347 |
+
elif min_coeff < 1e-05:
|
| 348 |
+
fmt = "%.6f"
|
| 349 |
+
elif min_coeff < 0.01:
|
| 350 |
+
fmt = "%.4f"
|
| 351 |
+
elif min_coeff < 1:
|
| 352 |
+
fmt = "%.3f"
|
| 353 |
+
elif min_coeff < 100:
|
| 354 |
+
fmt = "%.2f"
|
| 355 |
+
else:
|
| 356 |
+
fmt = "%.0f"
|
| 357 |
+
|
| 358 |
+
if coeff_range < 0.001:
|
| 359 |
+
step = 1e-06
|
| 360 |
+
elif coeff_range < 0.1:
|
| 361 |
+
step = 0.0001
|
| 362 |
+
elif coeff_range < 1:
|
| 363 |
+
step = 0.001
|
| 364 |
+
elif coeff_range < 10:
|
| 365 |
+
step = 0.01
|
| 366 |
+
elif coeff_range < 100:
|
| 367 |
+
step = 0.1
|
| 368 |
+
else:
|
| 369 |
+
step = 1.0
|
| 370 |
+
|
| 371 |
+
return step, fmt
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def is_method_enabled(method: str, resource: str, enabled_methods: dict) -> bool:
|
| 375 |
+
"""Check whether a method is enabled for a given resource.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
method (str): CDR method name.
|
| 379 |
+
resource (str): resource name.
|
| 380 |
+
enabled_methods (dict): {resource: {method: bool}} from session state.
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
bool: True if enabled (defaults to True when not explicitly set).
|
| 384 |
+
"""
|
| 385 |
+
return enabled_methods.get(resource, {}).get(method, True)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def is_custom_secondary(method_name: str, custom_r: str, custom_resource_to_sub_group: dict) -> bool:
|
| 389 |
+
"""Return True if the reference resource for custom_r is secondary for method_name.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
method_name (str): CDR method name.
|
| 393 |
+
custom_r (str): custom batch resource name.
|
| 394 |
+
custom_resource_to_sub_group (dict): {custom_r: reference_resource}.
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
bool
|
| 398 |
+
"""
|
| 399 |
+
sub_group = custom_resource_to_sub_group.get(custom_r)
|
| 400 |
+
return sub_group is not None and (method_name, sub_group) in secondary_resources
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ---------------------------------------------------------------------------
|
| 404 |
+
# JSON / results helpers (moved from tab4)
|
| 405 |
+
|
| 406 |
+
def to_json_safe(obj):
|
| 407 |
+
"""Recursively convert numpy/pandas types to plain Python so json.dumps works."""
|
| 408 |
+
if isinstance(obj, dict):
|
| 409 |
+
return {k: to_json_safe(v) for k, v in obj.items()}
|
| 410 |
+
if isinstance(obj, list):
|
| 411 |
+
return [to_json_safe(v) for v in obj]
|
| 412 |
+
if isinstance(obj, pd.Series):
|
| 413 |
+
return obj.iloc[0] if len(obj) == 1 else obj.tolist()
|
| 414 |
+
if isinstance(obj, pd.DataFrame):
|
| 415 |
+
return obj.to_dict(orient="records")
|
| 416 |
+
if isinstance(obj, np.integer):
|
| 417 |
+
return int(obj)
|
| 418 |
+
if isinstance(obj, np.floating):
|
| 419 |
+
return float(obj)
|
| 420 |
+
if isinstance(obj, np.ndarray):
|
| 421 |
+
return obj.tolist()
|
| 422 |
+
return obj
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def build_results_df(allocations: dict, method_constraints: dict | None = None) -> pd.DataFrame:
|
| 426 |
+
"""Build a DataFrame of per-method CO₂ allocations with share and cap columns.
|
| 427 |
+
|
| 428 |
+
Args:
|
| 429 |
+
allocations (dict): {method: CO₂ removed (float)}.
|
| 430 |
+
method_constraints (dict | None): {method: {cap_type, cap_value}} for the Cap column.
|
| 431 |
+
|
| 432 |
+
Returns:
|
| 433 |
+
pd.DataFrame: indexed by method, sorted descending by CO₂ removed.
|
| 434 |
+
"""
|
| 435 |
+
df = pd.DataFrame.from_dict(allocations, orient="index", columns=[f"{CO2_UNIT} removed"])
|
| 436 |
+
df[f"{CO2_UNIT} removed"] = pd.to_numeric(df[f"{CO2_UNIT} removed"], errors="coerce")
|
| 437 |
+
total = df[f"{CO2_UNIT} removed"].sum()
|
| 438 |
+
if total > 0:
|
| 439 |
+
df["Total share (%)"] = (df[f"{CO2_UNIT} removed"] / total * 100).round(2)
|
| 440 |
+
|
| 441 |
+
if method_constraints:
|
| 442 |
+
caps = {}
|
| 443 |
+
for m in df.index:
|
| 444 |
+
c = method_constraints.get(m, {})
|
| 445 |
+
if c.get("cap_type") == "absolute":
|
| 446 |
+
caps[m] = f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"
|
| 447 |
+
elif c.get("cap_type") == "percent":
|
| 448 |
+
caps[m] = f"{int(c.get('cap_value', 100))}% of potential"
|
| 449 |
+
else:
|
| 450 |
+
caps[m] = f"⚠️ Invalid type ({c.get('cap_type')!r}) — fix in Tab 2"
|
| 451 |
+
df["Cap"] = pd.Series(caps)
|
| 452 |
+
|
| 453 |
+
return df.sort_values(f"{CO2_UNIT} removed", ascending=False)
|
utils/state_utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def init_custom_resources():
|
| 5 |
+
"""Initialise custom resource session state keys and coerce amounts/coefficients to float.
|
| 6 |
+
|
| 7 |
+
Ensures custom_resources, enabled_methods, and auto_disabled_methods exist in
|
| 8 |
+
session state, then sanitises every batch so numeric fields are float (not str).
|
| 9 |
+
"""
|
| 10 |
+
if "custom_resources" not in st.session_state:
|
| 11 |
+
st.session_state.custom_resources = []
|
| 12 |
+
if "enabled_methods" not in st.session_state:
|
| 13 |
+
st.session_state.enabled_methods = {}
|
| 14 |
+
if "auto_disabled_methods" not in st.session_state:
|
| 15 |
+
st.session_state.auto_disabled_methods = set()
|
| 16 |
+
for batch in st.session_state.custom_resources:
|
| 17 |
+
try:
|
| 18 |
+
batch["amount"] = float(batch["amount"])
|
| 19 |
+
except (ValueError, TypeError):
|
| 20 |
+
batch["amount"] = 0.0
|
| 21 |
+
for m, coefs in batch.get("methods", {}).items():
|
| 22 |
+
for key in ("min", "median", "max"):
|
| 23 |
+
try:
|
| 24 |
+
coefs[key] = float(coefs[key])
|
| 25 |
+
except (ValueError, TypeError):
|
| 26 |
+
coefs[key] = 0.0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def clear_widget_keys():
|
| 30 |
+
"""Delete all Streamlit widget state keys for sliders, manual inputs, and toggles.
|
| 31 |
+
|
| 32 |
+
Called after loading a new file or scenario so widgets re-initialise from
|
| 33 |
+
fresh session-state values instead of showing stale cached values.
|
| 34 |
+
"""
|
| 35 |
+
prefixes = ("toggle_", "cap_type_", "slider_", "manual_")
|
| 36 |
+
for k in [k for k in st.session_state if k.startswith(prefixes)]:
|
| 37 |
+
del st.session_state[k]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def apply_auto_disable_logic(method_to_primary_resources, secondary_resources):
|
| 41 |
+
"""Synchronise auto-disabled method state based on current resource availability.
|
| 42 |
+
|
| 43 |
+
Runs three steps:
|
| 44 |
+
1. Count active primary resources per method (custom batches also count).
|
| 45 |
+
2. Re-enable any method previously auto-disabled that now has resources.
|
| 46 |
+
3. Disable any method whose primary resource count just dropped to zero.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
method_to_primary_resources (dict[str, list[str]]): method → list of
|
| 50 |
+
primary resource names (excludes secondary resources).
|
| 51 |
+
secondary_resources (set[tuple[str, str]]): set of (method, resource)
|
| 52 |
+
pairs where the resource is optional for that method.
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
None
|
| 56 |
+
"""
|
| 57 |
+
method_active_resource_count = {}
|
| 58 |
+
for r, methods in st.session_state.enabled_methods.items():
|
| 59 |
+
for m, ok in methods.items():
|
| 60 |
+
if (m, r) in secondary_resources:
|
| 61 |
+
continue
|
| 62 |
+
method_active_resource_count.setdefault(m, 0)
|
| 63 |
+
if ok:
|
| 64 |
+
method_active_resource_count[m] += 1
|
| 65 |
+
|
| 66 |
+
for batch in st.session_state.get("custom_resources", []):
|
| 67 |
+
if float(batch.get("amount") or 0) <= 0:
|
| 68 |
+
continue
|
| 69 |
+
for m_name in batch.get("methods", {}).keys():
|
| 70 |
+
method_active_resource_count.setdefault(m_name, 0)
|
| 71 |
+
method_active_resource_count[m_name] += 1
|
| 72 |
+
|
| 73 |
+
methods_with_no_active_resource = [
|
| 74 |
+
m for m in method_to_primary_resources
|
| 75 |
+
if method_active_resource_count.get(m, 0) == 0
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
for m in list(st.session_state.auto_disabled_methods):
|
| 79 |
+
if method_active_resource_count.get(m, 0) > 0:
|
| 80 |
+
if m in st.session_state.constraints:
|
| 81 |
+
st.session_state.constraints[m]["active"] = True
|
| 82 |
+
st.session_state.constraints[m]["cap_type"] = "percent"
|
| 83 |
+
st.session_state.constraints[m]["cap_value"] = 100
|
| 84 |
+
st.session_state[f"toggle_{m}"] = True
|
| 85 |
+
st.session_state[f"slider_{m}"] = 100
|
| 86 |
+
st.session_state.auto_disabled_methods.discard(m)
|
| 87 |
+
st.session_state.setdefault("just_activated_methods", set()).add(m)
|
| 88 |
+
|
| 89 |
+
for m in methods_with_no_active_resource:
|
| 90 |
+
st.session_state.auto_disabled_methods.add(m)
|
| 91 |
+
if st.session_state.constraints.get(m, {}).get("active", True):
|
| 92 |
+
st.session_state.constraints.setdefault(m, {"active": True, "cap_type": "percent", "cap_value": 100})
|
| 93 |
+
st.session_state.constraints[m]["active"] = False
|
| 94 |
+
toggle_key = f"toggle_{m}"
|
| 95 |
+
if toggle_key in st.session_state:
|
| 96 |
+
st.session_state[toggle_key] = False
|
utils/ui_helpers.py
CHANGED
|
@@ -3,6 +3,38 @@ import streamlit as st
|
|
| 3 |
from config.config import CRRA_COLORS, CRRA_FONT, PLOTLY_TEMPLATE
|
| 4 |
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
def apply_chart_style(fig, height=None):
|
| 7 |
"""Apply the standard CRRA theme (font, colours, margins) to a Plotly figure.
|
| 8 |
|
|
|
|
| 3 |
from config.config import CRRA_COLORS, CRRA_FONT, PLOTLY_TEMPLATE
|
| 4 |
|
| 5 |
|
| 6 |
+
def render_resource_input(name_clean, r, tooltip=None):
|
| 7 |
+
"""Render a number_input widget for a single resource availability cap.
|
| 8 |
+
|
| 9 |
+
Writes the entered value into st.session_state.resource_caps[r].
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
name_clean (str): display label (resource name without unit suffix).
|
| 13 |
+
r (str): canonical resource name used as the session state key.
|
| 14 |
+
tooltip (str | None): optional help text shown on hover.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
None
|
| 18 |
+
"""
|
| 19 |
+
widget_key = f"cap_{r}"
|
| 20 |
+
|
| 21 |
+
if widget_key not in st.session_state:
|
| 22 |
+
existing = st.session_state.resource_caps.get(r, 0.0)
|
| 23 |
+
st.session_state[widget_key] = existing if existing else None
|
| 24 |
+
|
| 25 |
+
cap = st.number_input(
|
| 26 |
+
label=name_clean,
|
| 27 |
+
min_value=0.0,
|
| 28 |
+
format="%.2f",
|
| 29 |
+
value=None,
|
| 30 |
+
placeholder="0.00",
|
| 31 |
+
key=widget_key,
|
| 32 |
+
help=tooltip,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
st.session_state.resource_caps[r] = cap if cap is not None else 0.0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
def apply_chart_style(fig, height=None):
|
| 39 |
"""Apply the standard CRRA theme (font, colours, margins) to a Plotly figure.
|
| 40 |
|