CarlyneB commited on
Commit
b5483eb
·
1 Parent(s): 818a3c4

[FEAT] Objective 3 – Additional data: amount of CO₂ (MtCO₂) removed if one unit of each resource is added

Browse files
optimization/optimization.py CHANGED
@@ -117,7 +117,9 @@ def run_optimization(
117
  resources_no_batches = [r for r in standard_resources if r not in ref_to_batches]
118
 
119
  # ── Build LP constraint matrix ────────────────────────────────────────────
120
- A_rows, b_rows = [], []
 
 
121
 
122
  # (A) Standard resources with NO custom batch substitutes — unchanged rows
123
  for r in resources_no_batches:
@@ -127,6 +129,7 @@ def run_optimization(
127
  if np.any(row[:M] != 0):
128
  A_rows.append(row)
129
  b_rows.append(resource_caps[r])
 
130
 
131
  # (B) Standard resources that DO have custom batch substitutes
132
  # Constraint: Σ_m c_s[m] * (y_m - Σ_n z_{m,n}) ≤ Q_s
@@ -141,6 +144,7 @@ def run_optimization(
141
  if np.any(row != 0):
142
  A_rows.append(row)
143
  b_rows.append(resource_caps[r])
 
144
 
145
  # (C) Custom batch capacity constraints: Σ_m c_n[m] * z_{m,n} ≤ Q_n
146
  for bname, info in custom_batch_info.items():
@@ -156,6 +160,7 @@ def run_optimization(
156
  if np.any(row != 0):
157
  A_rows.append(row)
158
  b_rows.append(resource_caps.get(bname, 0.0))
 
159
 
160
  # (D1) Coupling lower bound: Σ_{n: ref(n)=r} z_{m,n} ≤ y_m
161
  # Keeps standard resource usage non-negative.
@@ -181,6 +186,7 @@ def run_optimization(
181
  row[col] = 1.0
182
  A_rows.append(row)
183
  b_rows.append(0.0)
 
184
 
185
  # D2 (only when c_s = 0): y_m ≤ Σ z → y_m - Σ z ≤ 0
186
  if c_s_m == 0:
@@ -190,6 +196,7 @@ def run_optimization(
190
  row2[col] = -1.0
191
  A_rows.append(row2)
192
  b_rows.append(0.0)
 
193
 
194
  # (E) Method cap constraints (on y_m)
195
  for j, m in enumerate(methods):
@@ -201,6 +208,7 @@ def run_optimization(
201
  row[j] = 1.0
202
  A_rows.append(row)
203
  b_rows.append(cap_value)
 
204
  else:
205
  cap_ratio = cap_value / 100.0
206
  row = np.zeros(N)
@@ -208,6 +216,7 @@ def run_optimization(
208
  row[j] += 1.0
209
  A_rows.append(row)
210
  b_rows.append(0.0)
 
211
 
212
  A_ub = np.array(A_rows) if A_rows else np.empty((0, N))
213
  b_ub = np.array(b_rows)
@@ -276,14 +285,34 @@ def run_optimization(
276
  **{bn: info["amount"] for bn, info in custom_batch_info.items()}}
277
  resource_remaining = {r: all_caps.get(r, 0.0) - resource_used_total.get(r, 0.0)
278
  for r in all_caps}
279
- print(method_costs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
  return True, {
282
- "total_removed": total_removed,
283
- "method_usage": method_usage,
284
- "resource_usage": resource_usage,
285
- "resource_used_total":resource_used_total,
286
- "resource_remaining": resource_remaining,
 
287
  }
288
 
289
  except Exception as e:
 
117
  resources_no_batches = [r for r in standard_resources if r not in ref_to_batches]
118
 
119
  # ── Build LP constraint matrix ────────────────────────────────────────────
120
+ # constraint_labels tracks (type, identifier) for each row added to A_rows,
121
+ # enabling shadow-price extraction after the solve.
122
+ A_rows, b_rows, constraint_labels = [], [], []
123
 
124
  # (A) Standard resources with NO custom batch substitutes — unchanged rows
125
  for r in resources_no_batches:
 
129
  if np.any(row[:M] != 0):
130
  A_rows.append(row)
131
  b_rows.append(resource_caps[r])
132
+ constraint_labels.append(("resource", r))
133
 
134
  # (B) Standard resources that DO have custom batch substitutes
135
  # Constraint: Σ_m c_s[m] * (y_m - Σ_n z_{m,n}) ≤ Q_s
 
144
  if np.any(row != 0):
145
  A_rows.append(row)
146
  b_rows.append(resource_caps[r])
147
+ constraint_labels.append(("resource", r))
148
 
149
  # (C) Custom batch capacity constraints: Σ_m c_n[m] * z_{m,n} ≤ Q_n
150
  for bname, info in custom_batch_info.items():
 
160
  if np.any(row != 0):
161
  A_rows.append(row)
162
  b_rows.append(resource_caps.get(bname, 0.0))
163
+ constraint_labels.append(("custom_batch", bname))
164
 
165
  # (D1) Coupling lower bound: Σ_{n: ref(n)=r} z_{m,n} ≤ y_m
166
  # Keeps standard resource usage non-negative.
 
186
  row[col] = 1.0
187
  A_rows.append(row)
188
  b_rows.append(0.0)
189
+ constraint_labels.append(("coupling_D1", (m, ref)))
190
 
191
  # D2 (only when c_s = 0): y_m ≤ Σ z → y_m - Σ z ≤ 0
192
  if c_s_m == 0:
 
196
  row2[col] = -1.0
197
  A_rows.append(row2)
198
  b_rows.append(0.0)
199
+ constraint_labels.append(("coupling_D2", (m, ref)))
200
 
201
  # (E) Method cap constraints (on y_m)
202
  for j, m in enumerate(methods):
 
208
  row[j] = 1.0
209
  A_rows.append(row)
210
  b_rows.append(cap_value)
211
+ constraint_labels.append(("method_cap", m))
212
  else:
213
  cap_ratio = cap_value / 100.0
214
  row = np.zeros(N)
 
216
  row[j] += 1.0
217
  A_rows.append(row)
218
  b_rows.append(0.0)
219
+ constraint_labels.append(("method_cap", m))
220
 
221
  A_ub = np.array(A_rows) if A_rows else np.empty((0, N))
222
  b_ub = np.array(b_rows)
 
285
  **{bn: info["amount"] for bn, info in custom_batch_info.items()}}
286
  resource_remaining = {r: all_caps.get(r, 0.0) - resource_used_total.get(r, 0.0)
287
  for r in all_caps}
288
+
289
+ # ── Actual CDR gain per +1 unit (perturbation analysis) ──────────────
290
+ # For every resource, re-solve the LP with b_ub[i] += 1 (same constraint
291
+ # matrix, only the RHS changes) and measure the real gain. This correctly
292
+ # accounts for all simultaneously binding constraints.
293
+ resource_actual_gain = {}
294
+ for i, (ctype, cid) in enumerate(constraint_labels):
295
+ if ctype not in ("resource", "custom_batch"):
296
+ continue
297
+ b_perturbed = b_ub.copy()
298
+ b_perturbed[i] += 1.0
299
+ try:
300
+ perturbed = linprog(
301
+ c_obj, A_ub=A_ub, b_ub=b_perturbed, bounds=bounds, method="highs"
302
+ )
303
+ if perturbed.success:
304
+ new_total = float(perturbed.x[:M].sum())
305
+ resource_actual_gain[cid] = max(new_total - total_removed, 0.0)
306
+ except Exception:
307
+ pass
308
 
309
  return True, {
310
+ "total_removed": total_removed,
311
+ "method_usage": method_usage,
312
+ "resource_usage": resource_usage,
313
+ "resource_used_total": resource_used_total,
314
+ "resource_remaining": resource_remaining,
315
+ "resource_actual_gain": resource_actual_gain,
316
  }
317
 
318
  except Exception as e:
tabs/tab1_inputs.py CHANGED
@@ -2,9 +2,9 @@ import pandas as pd
2
  import streamlit as st
3
 
4
  from config.config import (resource_groups, resource_order, resource_tooltips,
5
- resource_unit_map, method_groups, method_order, CO2_UNIT, secondary_resources)
6
  from utils.data_utils import render_resource_input, strip_unit_suffix
7
- from utils.ui_helpers import format_resource_summary, format_table_summary, show_bar
8
  from io import BytesIO
9
 
10
  # ─── Custom resource helpers ──────────────────────────────────────────────────
@@ -588,14 +588,14 @@ def render_tab(cost_sliders):
588
  unit = resource_unit_map.get(group, "")
589
  with st.expander(f"**{group}** ({unit})", expanded=True):
590
 
591
- cols = st.columns(3)
592
 
593
  for i, r in enumerate(rlist):
594
 
595
  tooltip = resource_tooltips.get(r, None)
596
  name_clean = r.split(" (")[0]
597
 
598
- with cols[i % 3]:
599
 
600
  render_resource_input(name_clean, r, tooltip=tooltip)
601
 
 
2
  import streamlit as st
3
 
4
  from config.config import (resource_groups, resource_order, resource_tooltips,
5
+ resource_unit_map, method_groups, CO2_UNIT, secondary_resources)
6
  from utils.data_utils import render_resource_input, strip_unit_suffix
7
+ from utils.ui_helpers import format_resource_summary, show_bar
8
  from io import BytesIO
9
 
10
  # ─── Custom resource helpers ──────────────────────────────────────────────────
 
588
  unit = resource_unit_map.get(group, "")
589
  with st.expander(f"**{group}** ({unit})", expanded=True):
590
 
591
+ cols = st.columns(4)
592
 
593
  for i, r in enumerate(rlist):
594
 
595
  tooltip = resource_tooltips.get(r, None)
596
  name_clean = r.split(" (")[0]
597
 
598
+ with cols[i % 4]:
599
 
600
  render_resource_input(name_clean, r, tooltip=tooltip)
601
 
tabs/tab2_constraints.py CHANGED
@@ -4,7 +4,7 @@ import pandas as pd
4
  import streamlit as st
5
 
6
  from config.config import CO2_UNIT, method_groups, method_order, method_tooltips
7
- from utils.ui_helpers import format_table_summary, show_bar
8
 
9
 
10
  # ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -60,6 +60,7 @@ def render_method(m, cols, i):
60
  format_func=lambda x: "% of total potential"
61
  if x == "percent"
62
  else f"Absolute ({CO2_UNIT}/yr)",
 
63
  )
64
 
65
  st.session_state.constraints[m]["cap_type"] = cap_type
@@ -167,6 +168,18 @@ def render_tab():
167
  )
168
  df = build_summary_df()
169
  if df is not None:
170
- st.markdown(format_table_summary(df,"cdr-table"), unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
171
  else:
172
  st.warning("No active methods selected.")
 
4
  import streamlit as st
5
 
6
  from config.config import CO2_UNIT, method_groups, method_order, method_tooltips
7
+ from utils.ui_helpers import show_bar
8
 
9
 
10
  # ─── Helpers ─────────────────────────────────────────────────────────────────
 
60
  format_func=lambda x: "% of total potential"
61
  if x == "percent"
62
  else f"Absolute ({CO2_UNIT}/yr)",
63
+ label_visibility="collapsed",
64
  )
65
 
66
  st.session_state.constraints[m]["cap_type"] = cap_type
 
168
  )
169
  df = build_summary_df()
170
  if df is not None:
171
+ df_display = df.reset_index().rename(columns={"index": "CDR Method"})
172
+ df_display = df_display[["CDR Method", "Cap value"]]
173
+ num_rows = len(df_display)
174
+ st.dataframe(
175
+ df_display,
176
+ use_container_width=True,
177
+ hide_index=True,
178
+ height=(num_rows * 36 + 10),
179
+ column_config={
180
+ "CDR Method": st.column_config.TextColumn("CDR Method", width="large"),
181
+ "Cap value": st.column_config.TextColumn("Cap value"),
182
+ },
183
+ )
184
  else:
185
  st.warning("No active methods selected.")
tabs/tab3_coefficients.py CHANGED
@@ -25,8 +25,22 @@ def _get_slider_params(conf: dict) -> tuple:
25
  def is_method_enabled(method, resource, enabled_methods):
26
  return enabled_methods.get(resource, {}).get(method, True)
27
 
28
-
29
- def _enforce_disabled_coefficients(method_order_, resource_order_, custom_resources, enabled_methods):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  # Intentionally a no-op: disabling a method for a resource must NOT permanently
31
  # zero the stored coefficient in session state, because re-enabling would then
32
  # see an existing key at 0 and the pre-init would skip restoration.
@@ -73,7 +87,7 @@ def render_tab(cost_sliders):
73
  enabled_methods = st.session_state.get("enabled_methods", {})
74
  custom_resources = st.session_state.get("custom_resources", [])
75
 
76
- _enforce_disabled_coefficients(method_order, resource_order, custom_resources, enabled_methods)
77
 
78
  # Normalize cost_sliders once: strip unit suffixes from resource keys
79
  # cost_sliders_norm[method][resource_name] = {min, median, max}
@@ -231,6 +245,11 @@ def render_tab(cost_sliders):
231
 
232
  if slider_key not in st.session_state:
233
  st.session_state[slider_key] = current_val
 
 
 
 
 
234
 
235
  col1, col2 = st.columns([3, 1])
236
  with col1:
@@ -241,18 +260,20 @@ def render_tab(cost_sliders):
241
  step=step,
242
  format=fmt,
243
  key=slider_key,
 
244
  )
245
  with col2:
246
  manual_val = st.number_input(
247
  label="label",
248
  label_visibility="collapsed",
249
- value=slider_val,
250
  key=manual_key,
251
  help="Manual override (optional, any value)",
252
  format=fmt,
 
253
  )
254
 
255
- st.session_state.costs[method][r] = manual_val if manual_val != slider_val else slider_val
 
256
 
257
  # ── New (custom) resources linked to this method ────────────────
258
 
@@ -286,6 +307,12 @@ def render_tab(cost_sliders):
286
 
287
  if slider_key not in st.session_state:
288
  st.session_state[slider_key] = current_val
 
 
 
 
 
 
289
 
290
  col1, col2 = st.columns([3, 1])
291
  with col1:
@@ -296,19 +323,20 @@ def render_tab(cost_sliders):
296
  step=step,
297
  format=fmt,
298
  key=slider_key,
 
299
  )
300
  with col2:
301
  manual_val = st.number_input(
302
  label="label",
303
  label_visibility="collapsed",
304
- value=slider_val,
305
  key=manual_key,
306
  help="Manual override (optional, any value)",
307
  format=fmt,
 
308
  )
309
 
310
  st.session_state.costs.setdefault(method, {})
311
- st.session_state.costs[method][r] = manual_val if manual_val != slider_val else slider_val
312
 
313
 
314
  # ── Reset buttons ────────────────────────────────────────────────────
@@ -362,12 +390,14 @@ def render_tab(cost_sliders):
362
  df_current = df_current.rename(columns=resource_display_full)
363
 
364
  styled = format_efficiency_summary(df_current, df_default)
 
365
  st.dataframe(
366
  data=styled,
 
367
  use_container_width=True,
368
  column_config={
369
  "_index": st.column_config.TextColumn(
370
  "Method",
371
- # width="large"
372
- )
373
  })
 
25
  def is_method_enabled(method, resource, enabled_methods):
26
  return enabled_methods.get(resource, {}).get(method, True)
27
 
28
+ def _make_sync_s_to_m(slider_key, manual_key):
29
+ """on_change for slider: mirror its value into the number_input."""
30
+ def _sync():
31
+ st.session_state[manual_key] = st.session_state[slider_key]
32
+ return _sync
33
+
34
+ def _make_sync_m_to_s(slider_key, manual_key, min_val, max_val):
35
+ """on_change for number_input: clamp to [min, max] and sync both widgets."""
36
+ def _sync():
37
+ clamped = max(min_val, min(max_val, st.session_state[manual_key]))
38
+ st.session_state[manual_key] = clamped
39
+ st.session_state[slider_key] = clamped
40
+ return _sync
41
+
42
+
43
+ def _enforce_disabled_coefficients():
44
  # Intentionally a no-op: disabling a method for a resource must NOT permanently
45
  # zero the stored coefficient in session state, because re-enabling would then
46
  # see an existing key at 0 and the pre-init would skip restoration.
 
87
  enabled_methods = st.session_state.get("enabled_methods", {})
88
  custom_resources = st.session_state.get("custom_resources", [])
89
 
90
+ _enforce_disabled_coefficients()
91
 
92
  # Normalize cost_sliders once: strip unit suffixes from resource keys
93
  # cost_sliders_norm[method][resource_name] = {min, median, max}
 
245
 
246
  if slider_key not in st.session_state:
247
  st.session_state[slider_key] = current_val
248
+ if manual_key not in st.session_state:
249
+ st.session_state[manual_key] = st.session_state[slider_key]
250
+
251
+ if st.session_state.get("resource_caps", {}).get(r, 0) <= 0:
252
+ st.info(f"No available quantity set for **{r}** in Resource Inputs tab: **{method}** won't be used in the optimization.", icon=":material/info:")
253
 
254
  col1, col2 = st.columns([3, 1])
255
  with col1:
 
260
  step=step,
261
  format=fmt,
262
  key=slider_key,
263
+ on_change=_make_sync_s_to_m(slider_key, manual_key),
264
  )
265
  with col2:
266
  manual_val = st.number_input(
267
  label="label",
268
  label_visibility="collapsed",
 
269
  key=manual_key,
270
  help="Manual override (optional, any value)",
271
  format=fmt,
272
+ on_change=_make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
273
  )
274
 
275
+ st.session_state.costs[method][r] = st.session_state.get(manual_key, slider_val)
276
+
277
 
278
  # ── New (custom) resources linked to this method ────────────────
279
 
 
307
 
308
  if slider_key not in st.session_state:
309
  st.session_state[slider_key] = current_val
310
+ if manual_key not in st.session_state:
311
+ st.session_state[manual_key] = st.session_state[slider_key]
312
+
313
+ batch_amount = next((float(b.get("amount", 0)) for b in custom_resources if b["name"] == r), 0.0)
314
+ if batch_amount <= 0:
315
+ st.info(f"No available quantity set for **{r}** in Resource Inputs — this method won't be used in the optimization.", icon=":material/info:")
316
 
317
  col1, col2 = st.columns([3, 1])
318
  with col1:
 
323
  step=step,
324
  format=fmt,
325
  key=slider_key,
326
+ on_change=_make_sync_s_to_m(slider_key, manual_key),
327
  )
328
  with col2:
329
  manual_val = st.number_input(
330
  label="label",
331
  label_visibility="collapsed",
 
332
  key=manual_key,
333
  help="Manual override (optional, any value)",
334
  format=fmt,
335
+ on_change=_make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
336
  )
337
 
338
  st.session_state.costs.setdefault(method, {})
339
+ st.session_state.costs[method][r] = st.session_state.get(manual_key, slider_val)
340
 
341
 
342
  # ── Reset buttons ────────────────────────────────────────────────────
 
390
  df_current = df_current.rename(columns=resource_display_full)
391
 
392
  styled = format_efficiency_summary(df_current, df_default)
393
+ num_rows = len(df_current)
394
  st.dataframe(
395
  data=styled,
396
+ height=(num_rows*36 + 15),
397
  use_container_width=True,
398
  column_config={
399
  "_index": st.column_config.TextColumn(
400
  "Method",
401
+ width="large"
402
+ ),
403
  })
tabs/tab4_optimization.py CHANGED
@@ -1,4 +1,5 @@
1
  from collections import defaultdict
 
2
  import io
3
  import json
4
  import re
@@ -13,7 +14,7 @@ import plotly.express as px
13
 
14
  from config.config import CRRA_COLORS, PLOTLY_COLOR_SEQUENCE, PLOTLY_HEATMAP_SCALE, method_order, resource_order, resource_unit_map, resource_groups, secondary_resources, CO2_UNIT
15
  from optimization.optimization import run_optimization
16
- from utils.ui_helpers import apply_chart_style, format_table_summary, register_chart_data, show_bar
17
  from utils.data_utils import is_valid_scenario, load_inputs_from_excel, neutralize_zero_coefficient_methods, strip_unit_suffix
18
 
19
 
@@ -395,6 +396,7 @@ def run_and_store_result():
395
  "resource_usage": result["resource_usage"],
396
  "resource_used_total": result["resource_used_total"],
397
  "resource_remaining": result["resource_remaining"],
 
398
  "resource_caps": st.session_state.resource_caps.copy(),
399
  "method_constraints": st.session_state.constraints,
400
  "method_costs": st.session_state.costs,
@@ -664,6 +666,40 @@ def show_resource_balance(latest):
664
  st.dataframe(df, use_container_width=True, hide_index=True)
665
  register_chart_data("Resource balance (used vs remaining)", df)
666
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  # ─── Save / export / reset ───────────────────────────────────────────────────────────
668
 
669
  def save_scenario(latest):
@@ -736,9 +772,20 @@ def export_scenario():
736
  zf.writestr("method_costs.csv",
737
  pd.DataFrame.from_dict(scenario["method_costs"], orient="index").to_csv())
738
 
739
- zf.writestr("allocations.csv",
740
- pd.DataFrame.from_dict(scenario["allocations"], orient="index",
741
- columns=[f"{CO2_UNIT} removed"]).to_csv())
 
 
 
 
 
 
 
 
 
 
 
742
 
743
  custom_batches = scenario.get("custom_resources", [])
744
  if custom_batches:
@@ -768,22 +815,67 @@ def export_scenario():
768
 
769
  resource_used_total = scenario.get("resource_used_total", {})
770
  resource_remaining = scenario.get("resource_remaining", {})
 
 
 
 
771
  if resource_used_total or resource_remaining:
772
- df_balance = pd.DataFrame({
773
- "Used": pd.Series(resource_used_total),
774
- "Remaining": pd.Series(resource_remaining),
775
- })
776
- zf.writestr("resource_balance.csv", df_balance.to_csv())
 
 
 
 
 
 
 
 
 
 
 
 
 
777
 
778
  resource_usage = scenario.get("resource_usage", {})
779
  if resource_usage:
780
- usage_rows = [
781
- {"resource": r, "method": m, "used": qty}
782
- for r, methods in resource_usage.items()
783
- for m, qty in methods.items()
784
- ]
 
 
 
785
  zf.writestr("resource_usage_detail.csv", pd.DataFrame(usage_rows).to_csv(index=False))
786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  with col_csv:
788
  st.download_button(
789
  "Export CSV (zip)", data=buf.getvalue(),
@@ -1032,7 +1124,7 @@ def render_tab():
1032
  <p class="crra-sub-sub-heading">From inputs</p>""",
1033
  unsafe_allow_html=True,
1034
  )
1035
- run_clicked = st.button("▶️ Run Optimization", use_container_width=True)
1036
  panel_run_from_tabs()
1037
  panel_load_from_excel()
1038
 
@@ -1092,6 +1184,7 @@ def render_tab():
1092
  resource_usage=latest.get("resource_usage"))
1093
 
1094
  show_resource_balance(latest)
 
1095
  df_usage = build_resource_usage_df(latest["resource_usage"])
1096
 
1097
  st.markdown("### Detailed resource usage by method")
 
1
  from collections import defaultdict
2
+ import datetime
3
  import io
4
  import json
5
  import re
 
14
 
15
  from config.config import CRRA_COLORS, PLOTLY_COLOR_SEQUENCE, PLOTLY_HEATMAP_SCALE, method_order, resource_order, resource_unit_map, resource_groups, secondary_resources, CO2_UNIT
16
  from optimization.optimization import run_optimization
17
+ from utils.ui_helpers import apply_chart_style, register_chart_data, show_bar
18
  from utils.data_utils import is_valid_scenario, load_inputs_from_excel, neutralize_zero_coefficient_methods, strip_unit_suffix
19
 
20
 
 
396
  "resource_usage": result["resource_usage"],
397
  "resource_used_total": result["resource_used_total"],
398
  "resource_remaining": result["resource_remaining"],
399
+ "resource_actual_gain": result.get("resource_actual_gain", {}),
400
  "resource_caps": st.session_state.resource_caps.copy(),
401
  "method_constraints": st.session_state.constraints,
402
  "method_costs": st.session_state.costs,
 
666
  st.dataframe(df, use_container_width=True, hide_index=True)
667
  register_chart_data("Resource balance (used vs remaining)", df)
668
 
669
+ def show_bottleneck_analysis(latest):
670
+ st.markdown("### Resource sensitivity")
671
+ st.markdown(
672
+ '<p class="caption">'
673
+ 'For every resource: actual CDR gain if you add exactly +1 unit, recomputed with all other constraints in place.'
674
+ '</p>',
675
+ unsafe_allow_html=True,
676
+ )
677
+
678
+ resource_to_group = {
679
+ r: group for group, resources in resource_groups.items() for r in resources
680
+ }
681
+ custom_batch_lookup = {b["name"]: b for b in latest.get("custom_resources", [])}
682
+
683
+ actual_gains = latest.get("resource_actual_gain", {})
684
+ if actual_gains:
685
+ sp_rows = []
686
+ for r, gain in sorted(actual_gains.items(), key=lambda x: -x[1]):
687
+ if r in custom_batch_lookup:
688
+ unit = resource_unit_map.get(resource_to_group.get(custom_batch_lookup[r]["group"]), "?")
689
+ else:
690
+ unit = resource_unit_map.get(resource_to_group.get(r), "?")
691
+ sp_rows.append({
692
+ "Resource": r,
693
+ "Unit": unit,
694
+ f"CDR gain for +1 unit ({CO2_UNIT})": round(gain, 4),
695
+ })
696
+ df_sp = pd.DataFrame(sp_rows)
697
+ with st.expander("📊 View / download resource sensitivity", expanded=True):
698
+ st.dataframe(df_sp, use_container_width=True, hide_index=True)
699
+ register_chart_data("Resource sensitivity (CDR gain per +1 unit)", df_sp)
700
+ else:
701
+ st.info("No resources to analyse.")
702
+
703
  # ─── Save / export / reset ───────────────────────────────────────────────────────────
704
 
705
  def save_scenario(latest):
 
772
  zf.writestr("method_costs.csv",
773
  pd.DataFrame.from_dict(scenario["method_costs"], orient="index").to_csv())
774
 
775
+ alloc_df = pd.DataFrame.from_dict(scenario["allocations"], orient="index",
776
+ columns=[f"{CO2_UNIT} removed"])
777
+ alloc_df[f"{CO2_UNIT} removed"] = pd.to_numeric(alloc_df[f"{CO2_UNIT} removed"], errors="coerce")
778
+ alloc_total = alloc_df[f"{CO2_UNIT} removed"].sum()
779
+ if alloc_total > 0:
780
+ alloc_df["Total share (%)"] = (alloc_df[f"{CO2_UNIT} removed"] / alloc_total * 100).round(2)
781
+ _method_constraints = scenario.get("method_constraints", {})
782
+ alloc_df["Cap constraint"] = pd.Series({
783
+ m: (f"{c['cap_value']:,.2f} {CO2_UNIT}/yr" if c.get("cap_type") == "absolute"
784
+ else f"{int(c.get('cap_value', 100))}% of potential")
785
+ for m, c in _method_constraints.items()
786
+ })
787
+ alloc_df = alloc_df.sort_values(f"{CO2_UNIT} removed", ascending=False)
788
+ zf.writestr("allocations.csv", alloc_df.to_csv())
789
 
790
  custom_batches = scenario.get("custom_resources", [])
791
  if custom_batches:
 
815
 
816
  resource_used_total = scenario.get("resource_used_total", {})
817
  resource_remaining = scenario.get("resource_remaining", {})
818
+ _all_caps = dict(scenario.get("resource_caps", {}))
819
+ _custom_batches_map = {b["name"]: b for b in scenario.get("custom_resources", [])}
820
+ for _bn, _bi in _custom_batches_map.items():
821
+ _all_caps.setdefault(_bn, float(_bi["amount"]))
822
  if resource_used_total or resource_remaining:
823
+ balance_rows = []
824
+ for r, cap in _all_caps.items():
825
+ if cap <= 0:
826
+ continue
827
+ if r in _custom_batches_map:
828
+ _unit = resource_unit_map.get(resource_to_group.get(_custom_batches_map[r]["group"]), "?")
829
+ else:
830
+ _unit = resource_unit_map.get(resource_to_group.get(r), "?")
831
+ used = resource_used_total.get(r, 0.0)
832
+ balance_rows.append({
833
+ "Resource": r,
834
+ "Unit": _unit,
835
+ "Available amount": cap,
836
+ "Quantity used": used,
837
+ "Quantity remaining": resource_remaining.get(r, cap),
838
+ "% used": round(used / cap * 100, 1) if cap > 0 else 0,
839
+ })
840
+ zf.writestr("resource_balance.csv", pd.DataFrame(balance_rows).to_csv(index=False))
841
 
842
  resource_usage = scenario.get("resource_usage", {})
843
  if resource_usage:
844
+ usage_rows = []
845
+ for r, methods in resource_usage.items():
846
+ if r in _custom_batches_map:
847
+ _u = resource_unit_map.get(resource_to_group.get(_custom_batches_map[r]["group"]), "?")
848
+ else:
849
+ _u = resource_unit_map.get(resource_to_group.get(r), "?")
850
+ for m, qty in methods.items():
851
+ usage_rows.append({"resource": r, "unit": _u, "method": m, "used": qty})
852
  zf.writestr("resource_usage_detail.csv", pd.DataFrame(usage_rows).to_csv(index=False))
853
 
854
+ _actual_gains = scenario.get("resource_actual_gain", {})
855
+ if _actual_gains:
856
+ sens_rows = []
857
+ for r, gain in sorted(_actual_gains.items(), key=lambda x: -x[1]):
858
+ if r in _custom_batches_map:
859
+ _u = resource_unit_map.get(resource_to_group.get(_custom_batches_map[r]["group"]), "?")
860
+ else:
861
+ _u = resource_unit_map.get(resource_to_group.get(r), "?")
862
+ sens_rows.append({
863
+ "resource": r,
864
+ "unit": _u,
865
+ f"CDR_gain_for_+1_unit_{CO2_UNIT}": round(gain, 4),
866
+ })
867
+ zf.writestr("resource_sensitivity.csv", pd.DataFrame(sens_rows).to_csv(index=False))
868
+
869
+ _summary = scenario.get("summary", {})
870
+ summary_rows = [
871
+ {"metric": "Scenario name", "value": name, "unit": ""},
872
+ {"metric": "Export timestamp", "value": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "unit": ""},
873
+ {"metric": f"Total {CO2_UNIT} removed", "value": _summary.get("total_co2_removed", ""), "unit": CO2_UNIT},
874
+ {"metric": "Total cost (resource units sum)", "value": _summary.get("total_cost", ""), "unit": "mixed"},
875
+ {"metric": "Methods used", "value": len(scenario.get("allocations", {})), "unit": "count"},
876
+ ]
877
+ zf.writestr("summary.csv", pd.DataFrame(summary_rows).to_csv(index=False))
878
+
879
  with col_csv:
880
  st.download_button(
881
  "Export CSV (zip)", data=buf.getvalue(),
 
1124
  <p class="crra-sub-sub-heading">From inputs</p>""",
1125
  unsafe_allow_html=True,
1126
  )
1127
+ run_clicked = st.button("Run Optimization", icon=":material/play_arrow:", key="btn-run-optimization", type="primary")
1128
  panel_run_from_tabs()
1129
  panel_load_from_excel()
1130
 
 
1184
  resource_usage=latest.get("resource_usage"))
1185
 
1186
  show_resource_balance(latest)
1187
+ show_bottleneck_analysis(latest)
1188
  df_usage = build_resource_usage_df(latest["resource_usage"])
1189
 
1190
  st.markdown("### Detailed resource usage by method")
tabs/tab6_scenarios.py CHANGED
@@ -142,7 +142,7 @@ def render_tab():
142
  panel_load_scenario()
143
 
144
  if not st.session_state.scenarios:
145
- st.info("No scenarios saved yet. Run an optimization in tab 4 or load a file above.")
146
  return
147
 
148
  # Select & manage scenarios
 
142
  panel_load_scenario()
143
 
144
  if not st.session_state.scenarios:
145
+ st.info("No scenarios saved yet. Run an optimization in **Portfolio Optimization** tab or load a file above.")
146
  return
147
 
148
  # Select & manage scenarios