CarlyneB commited on
Commit
1026e02
·
1 Parent(s): 4198f14

[FEAT] Refactor tab2: widget sync (active method or not if resources added in tab1), type-switch reset, Other-method variants

Browse files
Files changed (1) hide show
  1. tabs/tab2_constraints.py +232 -29
tabs/tab2_constraints.py CHANGED
@@ -1,8 +1,9 @@
1
  import pandas as pd
2
  import streamlit as st
3
 
4
- from config.config import CO2_UNIT, method_groups, method_order, method_tooltips
5
  from utils.ui_helpers import show_bar
 
6
 
7
 
8
 
@@ -15,7 +16,42 @@ def default_constraint():
15
  return {"active": True, "cap_type": "percent", "cap_value": int(100)}
16
 
17
 
18
- def render_method(m, cols, i):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  """Render the constraint widget group (toggle + cap slider/input) for one method.
20
 
21
  Writes the result into st.session_state.constraints[m].
@@ -38,25 +74,57 @@ def render_method(m, cols, i):
38
 
39
  toggle_key = f"toggle_{m}"
40
  cap_type_key = f"cap_type_{m}"
41
- slider_key = f"slider_{m}"
42
- manual_key = f"manual_{m}"
43
 
44
- # init widgets ONLY from constraint (not reverse)
45
- st.session_state.setdefault(toggle_key, constraint["active"])
46
- st.session_state.setdefault(cap_type_key, constraint["cap_type"])
47
-
48
- if constraint["cap_type"] == "percent":
49
- st.session_state.setdefault(slider_key, int(constraint["cap_value"]))
50
- else:
51
- st.session_state.setdefault(manual_key, float(constraint["cap_value"]))
52
 
53
  is_active = st.toggle(m, key=toggle_key, help=tooltip)
54
 
55
  st.session_state.constraints[m]["active"] = is_active
56
 
 
 
 
 
 
57
  if not is_active:
 
58
  return
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  cap_type = st.radio(
61
  "Cap value type",
62
  ["percent", "absolute"],
@@ -70,31 +138,53 @@ def render_method(m, cols, i):
70
 
71
  st.session_state.constraints[m]["cap_type"] = cap_type
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  if cap_type == "percent":
 
 
 
 
 
 
 
 
74
 
75
  value = st.slider(
76
- "Max share (%)",
77
- 0, 100, step=5,
78
- key=slider_key,
79
  )
80
 
81
- st.session_state.constraints[m]["cap_value"] = value
82
-
83
  if value == 0:
84
  st.warning("Cap is 0% but method is active.", icon=":material/warning:")
85
 
86
  else:
 
 
 
 
87
  value = st.number_input(
88
  f"Max cap ({CO2_UNIT}/yr)",
89
  min_value=0.0,
90
  format="%.2f",
91
- value=None,
92
  placeholder="0.00",
93
- key=manual_key,
94
  )
95
 
96
- st.session_state.constraints[m]["cap_value"] = float(value) if value is not None else 0.0
97
-
98
  if not value:
99
  st.warning(f"Cap is 0 {CO2_UNIT}/yr but method is active.", icon=":material/warning:")
100
 
@@ -106,12 +196,27 @@ def reset_all_constraints():
106
  """
107
  for m in method_order:
108
  st.session_state.constraints[m] = default_constraint()
109
- for key in [f"toggle_type_{m}", f"cap_type_{m}", f"slider_{m}_widget", f"manual_{m}_widget"]:
110
  st.session_state.pop(key, None)
111
  st.success("All method constraints reset to default.")
112
  st.rerun()
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def build_summary_df():
116
  """Build a summary DataFrame of currently active methods and their caps.
117
 
@@ -119,25 +224,83 @@ def build_summary_df():
119
  pd.DataFrame | None: index = method name, column "Cap value" as a formatted
120
  string. Returns None if no method is active.
121
  """
 
122
  rows = {}
123
- for m in method_order:
124
- c = st.session_state.constraints[m]
125
  if not c["active"]:
126
  continue
127
  if c["cap_type"] == "percent":
128
- rows[m] = {"Cap value": f"{c['cap_value']}%"}
 
 
129
  else:
130
- rows[m] = {"Cap value": f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"}
131
  return pd.DataFrame.from_dict(rows, orient="index") if rows else None
132
 
133
 
134
 
135
- def render_tab():
136
  """Render the Method Constraints tab.
137
 
 
 
 
138
  Returns:
139
  None
140
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  st.markdown('<h1 class="crra-heading">Method constraints</h1>', unsafe_allow_html=True)
142
  show_bar()
143
 
@@ -162,6 +325,37 @@ def render_tab():
162
  unsafe_allow_html=True,
163
  )
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  with st.expander("Manage methods", expanded=True, icon=":material/settings:"):
166
  for group, method_list in method_groups.items():
167
  st.markdown(f"""
@@ -169,8 +363,17 @@ def render_tab():
169
  unsafe_allow_html=True,
170
  )
171
  cols = st.columns(3)
172
- for i, m in enumerate(method_list):
173
- render_method(m, cols, i)
 
 
 
 
 
 
 
 
 
174
 
175
  st.divider()
176
  if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
 
1
  import pandas as pd
2
  import streamlit as st
3
 
4
+ from config.config import CO2_UNIT, method_groups, method_order, method_tooltips, secondary_resources, hidden_resource_names
5
  from utils.ui_helpers import show_bar
6
+ from utils.data_utils import strip_unit_suffix, is_other_method, get_other_method_variants, make_variant_name
7
 
8
 
9
 
 
16
  return {"active": True, "cap_type": "percent", "cap_value": int(100)}
17
 
18
 
19
+ def _make_slider_cb(m):
20
+ """on_change for the percent-cap slider: write the new value into constraints."""
21
+ def _cb():
22
+ st.session_state.constraints[m]["cap_value"] = st.session_state[f"slider_{m}"]
23
+ return _cb
24
+
25
+
26
+ def _make_manual_cb(m):
27
+ """on_change for the absolute-cap number_input: write the new value into constraints."""
28
+ def _cb():
29
+ v = st.session_state.get(f"manual_{m}")
30
+ st.session_state.constraints[m]["cap_value"] = float(v) if v is not None else 0.0
31
+ return _cb
32
+
33
+
34
+ def get_hidden_resources_for_method(m, method_to_primary_resources, cost_sliders_norm):
35
+ """Return (required, optional) lists of hidden resource names for method m.
36
+
37
+ Returns (None, None) if the method has at least one non-hidden primary resource
38
+ or a custom batch already assigned.
39
+ """
40
+ primary = method_to_primary_resources.get(m, [])
41
+ if not primary or not all(r in hidden_resource_names for r in primary):
42
+ return None, None
43
+ custom_resources = st.session_state.get("custom_resources", [])
44
+ if any(m in b.get("methods", {}) for b in custom_resources):
45
+ return None, None
46
+ required = [r for r in primary if r in hidden_resource_names]
47
+ optional = [
48
+ r for r in cost_sliders_norm.get(m, {})
49
+ if r in hidden_resource_names and (m, r) in secondary_resources
50
+ ]
51
+ return required, optional
52
+
53
+
54
+ def render_method(m, cols, i, method_to_primary_resources, cost_sliders_norm):
55
  """Render the constraint widget group (toggle + cap slider/input) for one method.
56
 
57
  Writes the result into st.session_state.constraints[m].
 
74
 
75
  toggle_key = f"toggle_{m}"
76
  cap_type_key = f"cap_type_{m}"
 
 
77
 
78
+ was_active = constraint["active"]
79
+ st.session_state.setdefault(toggle_key, was_active)
 
 
 
 
 
 
80
 
81
  is_active = st.toggle(m, key=toggle_key, help=tooltip)
82
 
83
  st.session_state.constraints[m]["active"] = is_active
84
 
85
+ if is_active and not was_active:
86
+ st.session_state.constraints[m]["cap_type"] = "percent"
87
+ st.session_state.constraints[m]["cap_value"] = 100
88
+ st.session_state[cap_type_key] = "percent"
89
+
90
  if not is_active:
91
+ st.session_state.pop(cap_type_key, None)
92
  return
93
 
94
+ # If cap_type is invalid (e.g. "nan" from a corrupted import), show a
95
+ # warning and reset to "percent" so the radio and slider render correctly.
96
+ # The flag is cleared once the user interacts with the radio.
97
+ if constraint.get("_cap_type_invalid") or constraint.get("cap_type") not in ("percent", "absolute"):
98
+ st.warning(
99
+ f"Invalid **cap_type** value (`{constraint.get('cap_type')}`) loaded from file. "
100
+ "Select a constraint type below to fix it."
101
+ )
102
+ constraint["cap_type"] = "percent"
103
+ constraint["cap_value"] = 100
104
+ constraint.pop("_cap_type_invalid", None)
105
+ st.session_state.pop(cap_type_key, None)
106
+ st.session_state.pop(f"slider_{m}", None)
107
+ st.session_state.pop(f"manual_{m}", None)
108
+
109
+ # cap_type radio keeps a key so the user's choice persists across
110
+ # reruns; slider and number_input are keyless so they always read
111
+ # directly from the constraint — no stale session-state value possible.
112
+ st.session_state.setdefault(cap_type_key, constraint["cap_type"])
113
+
114
+ required, optional = get_hidden_resources_for_method(m, method_to_primary_resources, cost_sliders_norm)
115
+ if required is not None:
116
+ req_list = "\n".join(f"- **{r}** *(required)*" for r in required)
117
+ opt_list = "\n".join(f"- **{r}** *(optional)*" for r in optional) if optional else ""
118
+ body = f"To use this method, add a new resource in the **Resource inputs** tab:\n\n{req_list}"
119
+ if opt_list:
120
+ body += f"\n{opt_list}"
121
+ st.info(body, icon=":material/info:")
122
+ return
123
+
124
+ # Capture the cap_type that was active in the previous render so we can
125
+ # detect a mode switch (absolute → percent) below.
126
+ old_cap_type = constraint.get("cap_type", "percent")
127
+
128
  cap_type = st.radio(
129
  "Cap value type",
130
  ["percent", "absolute"],
 
138
 
139
  st.session_state.constraints[m]["cap_type"] = cap_type
140
 
141
+ # When the user switches from absolute → percent the stored cap_value is
142
+ # an absolute number (MtCO₂/yr) which is meaningless as a percentage.
143
+ # Reset both the constraint and the slider key to the default 100 % so
144
+ # neither value nor widget is "stuck" at whatever number was left over.
145
+ if cap_type == "percent" and old_cap_type == "absolute":
146
+ constraint["cap_value"] = 100
147
+ st.session_state[f"slider_{m}"] = 100
148
+ st.session_state.pop(f"manual_{m}", None)
149
+
150
+ if cap_type == "absolute" and old_cap_type == "percent":
151
+ constraint["cap_value"] = 0.0
152
+ st.session_state[f"manual_{m}"] = 0.0
153
+ st.session_state.pop(f"slider_{m}", None)
154
+
155
  if cap_type == "percent":
156
+ # Seed the slider key if absent or if it's spuriously 0 while the
157
+ # constraint still records a non-zero intent. constraint["cap_value"]
158
+ # is only written via on_change callbacks (user-initiated), so it
159
+ # faithfully reflects the user's last explicit choice even across tab
160
+ # navigations — making the discrepancy check reliable.
161
+ _cur = st.session_state.get(f"slider_{m}")
162
+ if _cur is None or (_cur == 0 and int(constraint["cap_value"]) > 0):
163
+ st.session_state[f"slider_{m}"] = int(constraint["cap_value"])
164
 
165
  value = st.slider(
166
+ "Max share (%)", 0, 100, step=5,
167
+ key=f"slider_{m}",
168
+ on_change=_make_slider_cb(m),
169
  )
170
 
 
 
171
  if value == 0:
172
  st.warning("Cap is 0% but method is active.", icon=":material/warning:")
173
 
174
  else:
175
+
176
+ if f"manual_{m}" not in st.session_state:
177
+ st.session_state[f"manual_{m}"] = float(constraint["cap_value"]) if constraint["cap_value"] else 0.0
178
+
179
  value = st.number_input(
180
  f"Max cap ({CO2_UNIT}/yr)",
181
  min_value=0.0,
182
  format="%.2f",
183
+ key=f"manual_{m}",
184
  placeholder="0.00",
185
+ on_change=_make_manual_cb(m),
186
  )
187
 
 
 
188
  if not value:
189
  st.warning(f"Cap is 0 {CO2_UNIT}/yr but method is active.", icon=":material/warning:")
190
 
 
196
  """
197
  for m in method_order:
198
  st.session_state.constraints[m] = default_constraint()
199
+ for key in [f"toggle_{m}", f"cap_type_{m}", f"slider_{m}", f"manual_{m}"]:
200
  st.session_state.pop(key, None)
201
  st.success("All method constraints reset to default.")
202
  st.rerun()
203
 
204
 
205
+ def _iter_display_methods(custom_resources):
206
+ """Yield (display_name, original_method_name) for all methods.
207
+
208
+ 'Other' methods with custom batches are expanded into one entry per batch.
209
+ All other methods yield (m, m).
210
+ """
211
+ for m in method_order:
212
+ variants = get_other_method_variants(m, custom_resources)
213
+ if variants:
214
+ for batch in variants:
215
+ yield make_variant_name(m, batch["name"]), m
216
+ else:
217
+ yield m, m
218
+
219
+
220
  def build_summary_df():
221
  """Build a summary DataFrame of currently active methods and their caps.
222
 
 
224
  pd.DataFrame | None: index = method name, column "Cap value" as a formatted
225
  string. Returns None if no method is active.
226
  """
227
+ custom_resources = st.session_state.get("custom_resources", [])
228
  rows = {}
229
+ for display_m, _ in _iter_display_methods(custom_resources):
230
+ c = st.session_state.constraints.get(display_m, default_constraint())
231
  if not c["active"]:
232
  continue
233
  if c["cap_type"] == "percent":
234
+ rows[display_m] = {"Cap value": f"{int(c['cap_value'])}%"}
235
+ elif c["cap_type"] == "absolute":
236
+ rows[display_m] = {"Cap value": f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"}
237
  else:
238
+ rows[display_m] = {"Cap value": f"⚠️ Invalid type ({c['cap_type']!r}) — fix in Tab 2"}
239
  return pd.DataFrame.from_dict(rows, orient="index") if rows else None
240
 
241
 
242
 
243
+ def render_tab(cost_sliders):
244
  """Render the Method Constraints tab.
245
 
246
+ Args:
247
+ cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}.
248
+
249
  Returns:
250
  None
251
  """
252
+ cost_sliders_norm = {
253
+ m: {strip_unit_suffix(name): conf for name, conf in resources.items()}
254
+ for m, resources in cost_sliders.items()
255
+ }
256
+ method_to_primary_resources = {
257
+ m: [r for r in resources if (m, r) not in secondary_resources]
258
+ for m, resources in cost_sliders_norm.items()
259
+ }
260
+
261
+ # Pre-render sync: drive alzzl widget keys from the constraint dict before
262
+ # any widget is drawn.
263
+ #
264
+ # Two modes:
265
+ # force mode – after a file/scenario load (constraints_just_loaded flag)
266
+ # or for freshly activated methods (just_activated_methods).
267
+ # All keys are set unconditionally from the constraint.
268
+ # normal mode – setdefault only; never overrides a value the user already
269
+ # set by interacting with the widget this session.
270
+ just_loaded = st.session_state.pop("constraints_just_loaded", False)
271
+ just_activated = st.session_state.pop("just_activated_methods", set())
272
+
273
+ for m, c in st.session_state.constraints.items():
274
+ force = just_loaded or (m in just_activated)
275
+ if c["active"]:
276
+ cap_type = c.get("cap_type", "percent")
277
+ if force:
278
+ st.session_state[f"toggle_{m}"] = True
279
+ st.session_state[f"cap_type_{m}"] = cap_type
280
+ if cap_type == "percent":
281
+ st.session_state[f"slider_{m}"] = int(c.get("cap_value", 100))
282
+ st.session_state.pop(f"manual_{m}", None)
283
+ else:
284
+ st.session_state[f"manual_{m}"] = float(c.get("cap_value", 0.0))
285
+ st.session_state.pop(f"slider_{m}", None)
286
+ else:
287
+ st.session_state.setdefault(f"toggle_{m}", True)
288
+ st.session_state.setdefault(f"cap_type_{m}", cap_type)
289
+ if cap_type == "percent":
290
+ st.session_state.setdefault(f"slider_{m}", int(c.get("cap_value", 100)))
291
+ st.session_state.pop(f"manual_{m}", None)
292
+ else:
293
+ st.session_state.setdefault(f"manual_{m}", float(c.get("cap_value", 0.0)))
294
+ st.session_state.pop(f"slider_{m}", None)
295
+ else:
296
+ if force:
297
+ st.session_state[f"toggle_{m}"] = False
298
+ else:
299
+ st.session_state.setdefault(f"toggle_{m}", False)
300
+ st.session_state.pop(f"cap_type_{m}", None)
301
+ st.session_state.pop(f"slider_{m}", None)
302
+ st.session_state.pop(f"manual_{m}", None)
303
+
304
  st.markdown('<h1 class="crra-heading">Method constraints</h1>', unsafe_allow_html=True)
305
  show_bar()
306
 
 
325
  unsafe_allow_html=True,
326
  )
327
 
328
+ custom_resources = st.session_state.get("custom_resources", [])
329
+
330
+ # Build the full list of display names (variants replace "Other" methods with batches)
331
+ all_display = list(_iter_display_methods(custom_resources))
332
+
333
+ any_active = any(
334
+ st.session_state.constraints.get(display_m, default_constraint()).get("active", True)
335
+ for display_m, _ in all_display
336
+ )
337
+ # Activatable: variants are always activatable; standard methods only if they
338
+ # have at least one visible (non-hidden) primary resource.
339
+ activatable = []
340
+ for display_m, orig_m in all_display:
341
+ if display_m != orig_m: # it's a variant → batch provides the resource
342
+ activatable.append(display_m)
343
+ elif get_hidden_resources_for_method(orig_m, method_to_primary_resources, cost_sliders_norm)[0] is None:
344
+ activatable.append(orig_m)
345
+
346
+ if any_active:
347
+ if st.button("Deactivate all", icon=":material/remove_done:", key="btn-deactivate-all"):
348
+ for display_m, _ in all_display:
349
+ st.session_state.constraints.setdefault(display_m, default_constraint())["active"] = False
350
+ st.session_state[f"toggle_{display_m}"] = False
351
+ st.rerun()
352
+ else:
353
+ if st.button("Activate all", icon=":material/done_all:", key="btn-activate-all"):
354
+ for m in activatable:
355
+ st.session_state.constraints.setdefault(m, default_constraint())["active"] = True
356
+ st.session_state[f"toggle_{m}"] = True
357
+ st.rerun()
358
+
359
  with st.expander("Manage methods", expanded=True, icon=":material/settings:"):
360
  for group, method_list in method_groups.items():
361
  st.markdown(f"""
 
363
  unsafe_allow_html=True,
364
  )
365
  cols = st.columns(3)
366
+ col_idx = 0
367
+ for m in method_list:
368
+ variants = get_other_method_variants(m, custom_resources)
369
+ if variants:
370
+ for batch in variants:
371
+ vname = make_variant_name(m, batch["name"])
372
+ render_method(vname, cols, col_idx, method_to_primary_resources, cost_sliders_norm)
373
+ col_idx += 1
374
+ else:
375
+ render_method(m, cols, col_idx, method_to_primary_resources, cost_sliders_norm)
376
+ col_idx += 1
377
 
378
  st.divider()
379
  if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):