CarlyneB commited on
Commit
2c0702f
·
1 Parent(s): 551ebbf

[REFACTOR] utils: centralize helpers — add state_utils, move render_resource_input to ui_helpers, consolidate data_utils

Browse files
Files changed (3) hide show
  1. utils/data_utils.py +221 -32
  2. utils/state_utils.py +96 -0
  3. 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 openpyxl
7
- from openpyxl.styles import PatternFill, Font
8
- from config.config import resource_unit_map, resource_order, method_order, resource_groups, method_groups
 
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 render_resource_input(name_clean, r, tooltip=None):
49
- """Render a number_input widget for a single resource availability cap.
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
- None
60
  """
61
- widget_key = f"cap_{r}"
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