CarlyneB commited on
Commit
2d250c5
·
1 Parent(s): c91fe21

[FEAT] Expand Other-method variants in LP, fix scenario load widget sync and resource balance display

Browse files
Files changed (1) hide show
  1. tabs/tab4_optimization.py +281 -162
tabs/tab4_optimization.py CHANGED
@@ -13,8 +13,9 @@ import plotly.express as px
13
  from config.config import CRRA_COLORS, PLOTLY_COLOR_SEQUENCE, PLOTLY_HEATMAP_SCALE, resource_order, resource_unit_map, resource_groups, secondary_resources, CO2_UNIT
14
  from optimization.optimization import run_optimization
15
  from utils.ui_helpers import apply_chart_style, register_chart_data, show_bar
16
- from utils.data_utils import is_valid_scenario, is_complete_scenario, load_inputs_from_excel, neutralize_zero_coefficient_methods, strip_unit_suffix, get_resource_to_group, get_resource_to_unit
17
-
 
18
 
19
  def to_json_safe(obj):
20
  """Recursively convert numpy/pandas types to plain Python so json.dumps works."""
@@ -84,7 +85,18 @@ def panel_load_from_excel():
84
  st.session_state.costs = costs
85
  st.session_state.custom_resources = custom_resources
86
  st.session_state.enabled_methods = enabled_methods
 
87
  clear_widget_keys()
 
 
 
 
 
 
 
 
 
 
88
  st.session_state.pop("latest_result", None)
89
  st.session_state.pop("scenario_loaded_file", None)
90
  st.session_state.pop("scenario_name_suggestion", None)
@@ -129,18 +141,23 @@ def parse_zip_scenario(uploaded) -> dict | None:
129
 
130
  # Compatibility with older files that used `max_share`
131
  if "cap_type (percent or absolute)" in df_c.columns:
132
- constraints = {
133
- m: {
134
- "active": bool(row["active"]),
135
- "cap_type": str(row["cap_type (percent or absolute)"]),
136
- "cap_value": float(row["cap_value"])
 
 
 
 
 
 
 
137
  }
138
- for m, row in df_c.iterrows()
139
- }
140
  else:
141
  constraints = {
142
  m: {
143
- "active": bool(row["active"]),
144
  "cap_type": "percent",
145
  "cap_value": float(row["max_share"])
146
  }
@@ -218,8 +235,9 @@ def parse_zip_scenario(uploaded) -> dict | None:
218
 
219
  if neutralized:
220
  st.info(
221
- f"ℹ️ {len(neutralized)} méthode(s) désactivée(s) automatiquement (aucune ressource active) : "
222
- + ", ".join(neutralized)
 
223
  )
224
 
225
  if success:
@@ -319,7 +337,20 @@ def panel_load_from_json_or_csv():
319
  # Restore custom resource batches if present in the scenario
320
  st.session_state.custom_resources = data.get("custom_resources", [])
321
  st.session_state.enabled_methods = data.get("enabled_methods", {})
 
 
 
 
322
  clear_widget_keys()
 
 
 
 
 
 
 
 
 
323
  st.session_state["scenario_loaded_file"] = uploaded_json.name
324
  st.session_state["scenario_name"] = filename_stem
325
  st.session_state["latest_result"] = data
@@ -338,7 +369,7 @@ def clear_widget_keys():
338
  Returns:
339
  None
340
  """
341
- prefixes = ("slider_", "manual_", "toggle_")
342
  for k in [k for k in st.session_state if k.startswith(prefixes)]:
343
  del st.session_state[k]
344
 
@@ -385,20 +416,75 @@ def run_and_store_result():
385
  if method_name in applied_costs and applied_costs[method_name].get(batch_name, 0.0) == 0.0:
386
  applied_costs[method_name][batch_name] = coefficients.get("median", 0.0)
387
 
388
- safe_constraints = neutralize_zero_coefficient_methods(
389
- applied_costs, st.session_state.constraints
390
- )
 
 
 
 
 
 
391
  neutralized = [
392
  m for m in safe_constraints
393
  if safe_constraints[m].get("active") is False
394
  and st.session_state.constraints.get(m, {}).get("active") is True
395
  ]
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  success, result = run_optimization(
398
- resource_caps=st.session_state.resource_caps,
399
  method_constraints=safe_constraints,
400
- method_costs=st.session_state.costs,
401
- custom_resources=custom_resources,
402
  enabled_methods=enabled_methods,
403
  )
404
 
@@ -410,8 +496,9 @@ def run_and_store_result():
410
 
411
  if neutralized:
412
  st.info(
413
- f"ℹ️ {len(neutralized)} méthode(s) désactivée(s) automatiquement (aucune ressource active) : "
414
- + ", ".join(neutralized)
 
415
  )
416
 
417
  allocations = result["method_usage"]
@@ -422,7 +509,7 @@ def run_and_store_result():
422
  return
423
 
424
  total_cost = sum(
425
- allocations[m] * sum(st.session_state.costs[m].values())
426
  for m in allocations
427
  )
428
  st.session_state.latest_result = {
@@ -432,14 +519,41 @@ def run_and_store_result():
432
  "resource_used_total": result["resource_used_total"],
433
  "resource_remaining": result["resource_remaining"],
434
  "resource_actual_gain": result.get("resource_actual_gain", {}),
435
- "resource_caps": st.session_state.resource_caps.copy(),
436
- "method_constraints": st.session_state.constraints,
437
- "method_costs": st.session_state.costs,
438
- "custom_resources": custom_resources,
439
  "enabled_methods": enabled_methods,
440
  }
441
  if custom_resources:
442
  st.info(f"ℹ️ {len(custom_resources)} new resource(s) included in optimisation.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  st.success(f"✅ {total_removed:,.4f} {CO2_UNIT} removed")
444
 
445
 
@@ -499,8 +613,10 @@ def build_results_df(allocations, method_constraints=None):
499
 
500
  if c.get("cap_type") == "absolute":
501
  caps[m] = f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"
502
- else:
503
  caps[m] = f"{int(c.get('cap_value', 100))}% of potential"
 
 
504
  df["Cap"] = pd.Series(caps)
505
 
506
  return df.sort_values(f"{CO2_UNIT} removed", ascending=False)
@@ -583,7 +699,7 @@ def show_heatmap(df_result, resource_caps, method_costs, custom_resources=None,
583
  df_heat, color_continuous_scale=PLOTLY_HEATMAP_SCALE, text_auto=".1f",
584
  labels=dict(x="Resource", y="Method", color="% Used"),
585
  )
586
- matrix_exact_fmt = [[f"{v:.4f}" for v in row] for row in matrix_exact]
587
  fig.update_traces(
588
  customdata=matrix_exact_fmt,
589
  hovertemplate="<b>%{y}</b><br>%{x}<br>% of cap: %{z:.1f}%<br>Exact: %{customdata}<extra></extra>",
@@ -705,9 +821,15 @@ def show_resource_balance(latest):
705
  remaining = latest["resource_remaining"]
706
 
707
  resource_to_group = get_resource_to_group()
708
-
 
 
 
 
709
  rows = []
710
  for r in resource_caps:
 
 
711
  group = resource_to_group.get(r)
712
  unit = resource_unit_map.get(group)
713
  cap = resource_caps[r]
@@ -718,22 +840,29 @@ def show_resource_balance(latest):
718
  "Resource": f"{r} ({unit})",
719
  "Available amount": cap,
720
  "Quantity used": used,
721
- "Quantity remaining": remaining.get(r, cap),
722
- "% used": round(used / cap * 100, 1) if cap > 0 else 0,
723
  })
724
 
725
  for batch in latest.get("custom_resources", []):
726
  r = batch["name"]
727
- cap = float(batch["amount"])
 
 
 
728
  used = used_total.get(r, 0.0)
 
 
 
729
  rows.append({
730
- "Resource": r, "Available amount": cap, "Quantity used": used,
731
- "Quantity remaining": remaining.get(r, cap),
732
- "% used": round(used / cap * 100, 1) if cap > 0 else 0,
 
 
733
  })
734
 
735
  df = pd.DataFrame(rows).sort_values("% used", ascending=False)
736
-
737
  fig = px.bar(
738
  df, x="Resource", y=["Quantity used", "Quantity remaining"], barmode="stack",
739
  color_discrete_map={"Quantity used": CRRA_COLORS["green"], "Quantity remaining": CRRA_COLORS["off_white"]},
@@ -779,8 +908,7 @@ def show_bottleneck_analysis(latest):
779
  f"CDR gain for +1 unit ({CO2_UNIT})": round(gain, 4),
780
  })
781
  df_sp = pd.DataFrame(sp_rows)
782
- with st.expander("📊 View / download resource sensitivity", expanded=True):
783
- st.dataframe(df_sp, width='stretch', hide_index=True)
784
  register_chart_data("Resource sensitivity (CDR gain per +1 unit)", df_sp)
785
  else:
786
  st.info("No resources to analyse.")
@@ -812,15 +940,17 @@ def save_scenario(latest):
812
  placeholder="Enter a scenario name",
813
  )
814
  with col_btn:
815
- if st.button("Save", width='stretch', icon=":material/save:"):
816
- name = st.session_state.get("scenario_name", "").strip()
817
- if not name:
818
- st.warning("Please enter a name.")
819
- elif name in st.session_state.scenarios:
820
- st.warning("Name already exists.")
821
- else:
822
- st.session_state.scenarios[name] = latest
823
- st.success(f"Scenario '{name}' saved for comparison.")
 
 
824
 
825
  def export_scenario():
826
  """Render JSON and ZIP export download buttons for the currently saved scenario.
@@ -992,7 +1122,7 @@ def export_inputs_only():
992
  """
993
  resource_to_unit = get_resource_to_unit()
994
  st.markdown('<p class="crra-sub-heading">Export inputs</p>', unsafe_allow_html=True)
995
- st.markdown('<p class="caption">Export only the resource capabilities, method constraints and coefficients — without results.</p>', unsafe_allow_html=True)
996
 
997
  suggested = st.session_state.get("excel_loaded_file", "").rsplit(".", 1)[0]
998
  # Update when a new file is loaded
@@ -1002,86 +1132,75 @@ def export_inputs_only():
1002
 
1003
  resource_to_group = get_resource_to_group()
1004
 
1005
- col_text, col_btn = st.columns([3, 1])
1006
- with col_text:
1007
- st.text_input(
1008
- "File name",
1009
- label_visibility="collapsed",
1010
- key="input_file_name",
1011
- placeholder="Enter a file name",
1012
- )
1013
- with col_btn:
1014
- prepare = st.button("Prepare export", width='stretch', icon=":material/file_export:")
1015
-
1016
- if prepare:
1017
- safe = st.session_state.get("input_file_name", "").strip() or suggested or "crra_inputs"
1018
-
1019
- enabled_methods = st.session_state.get("enabled_methods", {})
1020
-
1021
- def is_allowed(method, resource):
1022
- return enabled_methods.get(resource, {}).get(method, True)
1023
-
1024
- wb = openpyxl.Workbook()
1025
- ws1 = wb.active
1026
- ws1.title = "resource_caps"
1027
- ws1.append(["Resource", "Unit", "Available Amount"])
1028
- for r, v in st.session_state.resource_caps.items():
1029
- ws1.append([r, resource_to_unit.get(r, ""), v])
1030
-
1031
- ws2 = wb.create_sheet("constraints")
1032
- ws2.append(["method", "active", "cap_type (percent or absolute)", "cap_value"])
1033
- for m, c in st.session_state.constraints.items():
1034
- ws2.append([m, c.get("active", True), c.get("cap_type", "percent"), c.get("cap_value", 100)])
1035
-
1036
- # Coefficients — a disabled (method, resource) pair is always exported
1037
- # as 0, regardless of whether the user ever opened it in tab3. This
1038
- # keeps the export consistent with what the optimizer actually uses.
1039
- ws3 = wb.create_sheet("coefficients")
1040
- ws3.append(["method", "resource", "value", "secondary"])
1041
- for m, resources in st.session_state.costs.items():
1042
- for r, v in resources.items():
1043
- value = 0.0 if not is_allowed(m, r) else v
1044
- ws3.append([m, r, value, (m, r) in secondary_resources])
1045
-
1046
- ws4 = wb.create_sheet("custom_resources")
1047
- ws4.append(["name", "group", "amount", "unit", "method", "min", "median", "max"])
1048
- for batch in st.session_state.get("custom_resources", []):
1049
- unit = resource_unit_map.get(resource_to_group.get(batch["group"]))
1050
- for method, c in batch["methods"].items():
1051
- ws4.append([
1052
- batch["name"],
1053
- batch["group"],
1054
- batch["amount"],
1055
- unit,
1056
- method,
1057
- c["min"],
1058
- c["median"],
1059
- c["max"],
1060
- ])
1061
-
1062
- # Per-resource manual method allocation (objective 2) — which CDR
1063
- # methods are allowed to use each resource batch.
1064
- ws5 = wb.create_sheet("enabled_methods")
1065
- ws5.append(["resource", "method", "enabled"])
1066
- for r, methods in enabled_methods.items():
1067
- for m, ok in methods.items():
1068
- ws5.append([r, m, bool(ok)])
1069
-
1070
- excel_buffer = io.BytesIO()
1071
- wb.save(excel_buffer)
1072
-
1073
- st.session_state["_input_export_buf"] = excel_buffer.getvalue()
1074
- st.session_state["_input_export_name"] = safe
1075
-
1076
- if "_input_export_buf" in st.session_state:
1077
- st.download_button(
1078
- f"Download **{st.session_state['_input_export_name']}.xlsx**",
1079
- data=st.session_state["_input_export_buf"],
1080
- file_name=f"{st.session_state['_input_export_name']}.xlsx",
1081
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1082
- width='stretch',
1083
- icon=":material/download:"
1084
- )
1085
 
1086
  def export_all_charts():
1087
  """Render a download widget for all chart data registered in this session.
@@ -1104,48 +1223,46 @@ def export_all_charts():
1104
  st.info("No charts have been generated yet in this session.")
1105
  return
1106
 
1107
- col_name, col_fmt = st.columns([2, 1])
1108
- with col_name:
1109
- base_name = st.text_input(
1110
- "File name",
1111
- value="charts_data",
1112
- key="charts_export_name",
1113
- help="The extension (.xlsx or .zip) is added automatically.",
1114
- )
1115
- with col_fmt:
1116
- fmt = st.radio(
1117
- "Format", ["Excel (.xlsx, one sheet per chart)", "ZIP (one CSV per chart)"],
1118
- horizontal=True, key="chart_export_format",
1119
- )
1120
 
1121
- safe_name = base_name.strip() or "charts_data"
 
 
 
 
 
 
 
 
 
 
 
1122
 
1123
- if fmt.startswith("Excel"):
1124
- export_buffer = io.BytesIO()
1125
- with pd.ExcelWriter(export_buffer, engine="openpyxl") as writer:
1126
- for name, df in registry.items():
1127
- sheet_name = name[:31] # limite Excel
1128
- df.to_excel(writer, sheet_name=sheet_name, index=False)
1129
  st.download_button(
1130
- f"Download {safe_name}.xlsx",
1131
- data=export_buffer.getvalue(),
1132
  file_name=f"{safe_name}.xlsx",
1133
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1134
- icon=":material/download:"
 
1135
  )
1136
- else:
1137
- export_buffer = io.BytesIO()
1138
- with zipfile.ZipFile(export_buffer, "w") as zf:
1139
- for name, df in registry.items():
1140
- safe_chart_name = name.replace(" ", "_").replace("/", "-").replace("(", "").replace(")", "")
1141
- zf.writestr(f"{safe_chart_name}.csv", df.to_csv(index=False))
1142
-
1143
  st.download_button(
1144
- f"Download {safe_name}.zip",
1145
- data=export_buffer.getvalue(),
1146
  file_name=f"{safe_name}.zip",
1147
  mime="application/zip",
1148
- icon=":material/download:"
 
1149
  )
1150
 
1151
  def clear_all_data():
@@ -1274,6 +1391,7 @@ def render_tab():
1274
  unsafe_allow_html=True,
1275
  )
1276
  run_clicked = st.button("Run Optimization", icon=":material/play_arrow:", key="btn-run-optimization", type="primary")
 
1277
  panel_run_from_tabs()
1278
  panel_load_from_excel()
1279
 
@@ -1285,7 +1403,8 @@ def render_tab():
1285
  panel_load_from_json_or_csv()
1286
 
1287
  if run_clicked:
1288
- run_and_store_result()
 
1289
 
1290
  _no_results = '<p class="caption">Run the optimization first to see results here.</p>'
1291
 
 
13
  from config.config import CRRA_COLORS, PLOTLY_COLOR_SEQUENCE, PLOTLY_HEATMAP_SCALE, resource_order, resource_unit_map, resource_groups, secondary_resources, CO2_UNIT
14
  from optimization.optimization import run_optimization
15
  from utils.ui_helpers import apply_chart_style, register_chart_data, show_bar
16
+ from utils.data_utils import is_valid_scenario, is_complete_scenario, load_inputs_from_excel, neutralize_zero_coefficient_methods, strip_unit_suffix, get_resource_to_group, get_resource_to_unit, is_other_method, get_other_method_variants, make_variant_name
17
+ from config.config import hidden_resource_names as _hidden
18
+ from tabs.tab2_constraints import default_constraint as _dc
19
 
20
  def to_json_safe(obj):
21
  """Recursively convert numpy/pandas types to plain Python so json.dumps works."""
 
85
  st.session_state.costs = costs
86
  st.session_state.custom_resources = custom_resources
87
  st.session_state.enabled_methods = enabled_methods
88
+ st.session_state.auto_disabled_methods = set()
89
  clear_widget_keys()
90
+ for _m, _c in constraints.items():
91
+ st.session_state[f"toggle_{_m}"] = bool(_c.get("active", True))
92
+ if _c.get("active", True):
93
+ st.session_state[f"cap_type_{_m}"] = _c.get("cap_type", "percent")
94
+ if _c.get("cap_type", "percent") == "percent":
95
+ st.session_state[f"slider_{_m}"] = int(_c.get("cap_value", 100))
96
+ else:
97
+ st.session_state[f"manual_{_m}"] = float(_c.get("cap_value", 0.0))
98
+ # Signal tab2 to force-refresh all widget keys on next visit.
99
+ st.session_state["constraints_just_loaded"] = True
100
  st.session_state.pop("latest_result", None)
101
  st.session_state.pop("scenario_loaded_file", None)
102
  st.session_state.pop("scenario_name_suggestion", None)
 
141
 
142
  # Compatibility with older files that used `max_share`
143
  if "cap_type (percent or absolute)" in df_c.columns:
144
+ _VALID_CAP_TYPES = {"percent", "absolute"}
145
+ constraints = {}
146
+ for m, row in df_c.iterrows():
147
+ raw_ct = str(row["cap_type (percent or absolute)"])
148
+ raw_active = row["active"]
149
+ constraints[m] = {
150
+ "active": raw_active is True or str(raw_active).strip().lower() == "true",
151
+ # Store the raw value even if invalid so the UI can display
152
+ # a warning and let the user correct it without re-importing.
153
+ "cap_type": raw_ct if raw_ct in _VALID_CAP_TYPES else raw_ct,
154
+ "cap_value": float(row["cap_value"]),
155
+ "_cap_type_invalid": raw_ct not in _VALID_CAP_TYPES,
156
  }
 
 
157
  else:
158
  constraints = {
159
  m: {
160
+ "active": row["active"] is True or str(row["active"]).strip().lower() == "true",
161
  "cap_type": "percent",
162
  "cap_value": float(row["max_share"])
163
  }
 
235
 
236
  if neutralized:
237
  st.info(
238
+ f"{len(neutralized)} method(s) automatically deactivated (no active resource): "
239
+ + ", ".join(neutralized),
240
+ icon=":material/info:",
241
  )
242
 
243
  if success:
 
337
  # Restore custom resource batches if present in the scenario
338
  st.session_state.custom_resources = data.get("custom_resources", [])
339
  st.session_state.enabled_methods = data.get("enabled_methods", {})
340
+ # Reset auto_disabled_methods so tab1's re-enable logic starts from a
341
+ # clean slate matching the loaded scenario (avoids stale entries that
342
+ # would prevent or incorrectly trigger re-enable on the next tab1 visit).
343
+ st.session_state.auto_disabled_methods = set()
344
  clear_widget_keys()
345
+ for _m, _c in st.session_state.constraints.items():
346
+ st.session_state[f"toggle_{_m}"] = bool(_c.get("active", True))
347
+ if _c.get("active", True):
348
+ st.session_state[f"cap_type_{_m}"] = _c.get("cap_type", "percent")
349
+ if _c.get("cap_type", "percent") == "percent":
350
+ st.session_state[f"slider_{_m}"] = int(_c.get("cap_value", 100))
351
+ else:
352
+ st.session_state[f"manual_{_m}"] = float(_c.get("cap_value", 0.0))
353
+ st.session_state["constraints_just_loaded"] = True
354
  st.session_state["scenario_loaded_file"] = uploaded_json.name
355
  st.session_state["scenario_name"] = filename_stem
356
  st.session_state["latest_result"] = data
 
369
  Returns:
370
  None
371
  """
372
+ prefixes = ("toggle_", "cap_type_", "slider_", "manual_")
373
  for k in [k for k in st.session_state if k.startswith(prefixes)]:
374
  del st.session_state[k]
375
 
 
416
  if method_name in applied_costs and applied_costs[method_name].get(batch_name, 0.0) == 0.0:
417
  applied_costs[method_name][batch_name] = coefficients.get("median", 0.0)
418
 
419
+ # Only neutralize standard methods (those present in applied_costs).
420
+ # Variant names from previous runs may exist in st.session_state.constraints
421
+ # but are not in applied_costs yet — passing them would cause false positives
422
+ # (empty cost dict → all_zero → auto-deactivated before expansion runs).
423
+ standard_constraints = {
424
+ m: c for m, c in st.session_state.constraints.items()
425
+ if m in applied_costs
426
+ }
427
+ safe_constraints = neutralize_zero_coefficient_methods(applied_costs, standard_constraints)
428
  neutralized = [
429
  m for m in safe_constraints
430
  if safe_constraints[m].get("active") is False
431
  and st.session_state.constraints.get(m, {}).get("active") is True
432
  ]
433
 
434
+ # Expand "Other" methods: replace each with one variant per assigned batch.
435
+ # Each variant uses the batch as its primary resource (standard LP constraint)
436
+ # instead of the z-variable substitution mechanism.
437
+ resource_caps_for_lp = dict(st.session_state.resource_caps)
438
+ custom_resources_for_lp = []
439
+ expanded_batch_names: set[str] = set()
440
+
441
+ for m in list(applied_costs.keys()):
442
+ if not is_other_method(m):
443
+ continue
444
+ batches_for_m = get_other_method_variants(m, custom_resources)
445
+ if not batches_for_m:
446
+ continue
447
+ # All batch names for this "Other" method — must be excluded from each
448
+ # variant's base costs so variants don't inherit each other's batches.
449
+ all_batch_names_for_m = {b["name"] for b in batches_for_m}
450
+
451
+ for batch in batches_for_m:
452
+ vname = make_variant_name(m, batch["name"])
453
+ # Inherit only non-hidden, non-batch secondary resources from the
454
+ # original method, then add THIS batch as the sole primary resource.
455
+ variant_costs = {
456
+ r: coeff for r, coeff in applied_costs[m].items()
457
+ if r not in _hidden and r not in all_batch_names_for_m
458
+ }
459
+ variant_costs[batch["name"]] = applied_costs[m].get(
460
+ batch["name"],
461
+ batch["methods"][m]["median"],
462
+ )
463
+ applied_costs[vname] = variant_costs
464
+ # Variant constraint: prefer an explicit user-set value, else default.
465
+ safe_constraints[vname] = (
466
+ st.session_state.constraints.get(vname) or _dc()
467
+ )
468
+ # Batch becomes a standard capped resource for the LP.
469
+ resource_caps_for_lp[batch["name"]] = float(batch["amount"])
470
+ expanded_batch_names.add(batch["name"])
471
+ # Remove the original "Other" method — replaced by variants.
472
+ del applied_costs[m]
473
+ del safe_constraints[m]
474
+
475
+ # LP receives only non-expanded batches. Expanded batches are now standard
476
+ # resources (in resource_caps_for_lp) so they get a type-A constraint — if
477
+ # they stayed in custom_resources they'd land in custom_batch_names, be
478
+ # excluded from standard_resources, and generate no constraint → unbounded LP.
479
+ custom_resources_for_lp = [b for b in custom_resources if b["name"] not in expanded_batch_names]
480
+ # Display functions keep the full list so group/unit info is available.
481
+ custom_resources_for_display = list(custom_resources)
482
+
483
  success, result = run_optimization(
484
+ resource_caps=resource_caps_for_lp,
485
  method_constraints=safe_constraints,
486
+ method_costs=applied_costs,
487
+ custom_resources=custom_resources_for_lp,
488
  enabled_methods=enabled_methods,
489
  )
490
 
 
496
 
497
  if neutralized:
498
  st.info(
499
+ f"{len(neutralized)} method(s) automatically deactivated (no active resource): "
500
+ + ", ".join(neutralized),
501
+ icon=":material/info:",
502
  )
503
 
504
  allocations = result["method_usage"]
 
509
  return
510
 
511
  total_cost = sum(
512
+ allocations[m] * sum(applied_costs.get(m, st.session_state.costs.get(m, {})).values())
513
  for m in allocations
514
  )
515
  st.session_state.latest_result = {
 
519
  "resource_used_total": result["resource_used_total"],
520
  "resource_remaining": result["resource_remaining"],
521
  "resource_actual_gain": result.get("resource_actual_gain", {}),
522
+ "resource_caps": resource_caps_for_lp,
523
+ "method_constraints": safe_constraints,
524
+ "method_costs": applied_costs,
525
+ "custom_resources": custom_resources_for_display,
526
  "enabled_methods": enabled_methods,
527
  }
528
  if custom_resources:
529
  st.info(f"ℹ️ {len(custom_resources)} new resource(s) included in optimisation.")
530
+
531
+ # Warn when an active method has a primary resource whose coefficient is so
532
+ # small (< 1e-7) that it is effectively unconstrained — the LP will run the
533
+ # method freely regardless of how much of that resource is available.
534
+ _COEFF_WARN = 1e-7
535
+ near_zero_warnings = []
536
+ for m, qty in allocations.items():
537
+ if qty <= 0:
538
+ continue
539
+ for r, coeff in applied_costs.get(m, {}).items():
540
+ if (m, r) in secondary_resources:
541
+ continue
542
+ if r not in st.session_state.resource_caps:
543
+ continue
544
+ if 0 < coeff < _COEFF_WARN:
545
+ near_zero_warnings.append((m, r, coeff))
546
+ if near_zero_warnings:
547
+ lines = "\n".join(
548
+ f"- **{m}** \n {r}: coefficient = {coeff:.2e} (effectively 0 — resource not constraining)"
549
+ for m, r, coeff in near_zero_warnings
550
+ )
551
+ st.warning(
552
+ f"The following primary resources have near-zero coefficients and do **not** "
553
+ f"constrain the method: the method can run regardless of their availability:\n\n{lines}",
554
+ icon=":material/warning:",
555
+ )
556
+
557
  st.success(f"✅ {total_removed:,.4f} {CO2_UNIT} removed")
558
 
559
 
 
613
 
614
  if c.get("cap_type") == "absolute":
615
  caps[m] = f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"
616
+ elif c.get("cap_type") == "percent":
617
  caps[m] = f"{int(c.get('cap_value', 100))}% of potential"
618
+ else:
619
+ caps[m] = f"⚠️ Invalid type ({c.get('cap_type')!r}) — fix in Tab 2"
620
  df["Cap"] = pd.Series(caps)
621
 
622
  return df.sort_values(f"{CO2_UNIT} removed", ascending=False)
 
699
  df_heat, color_continuous_scale=PLOTLY_HEATMAP_SCALE, text_auto=".1f",
700
  labels=dict(x="Resource", y="Method", color="% Used"),
701
  )
702
+ matrix_exact_fmt = [[f"{v:.15f}".rstrip("0").rstrip(".") for v in row] for row in matrix_exact]
703
  fig.update_traces(
704
  customdata=matrix_exact_fmt,
705
  hovertemplate="<b>%{y}</b><br>%{x}<br>% of cap: %{z:.1f}%<br>Exact: %{customdata}<extra></extra>",
 
821
  remaining = latest["resource_remaining"]
822
 
823
  resource_to_group = get_resource_to_group()
824
+ # Custom batch names: their amounts come from resource_caps (expanded batches
825
+ # are also in resource_caps) but we display them via the custom_resources loop
826
+ # to retain group/unit info — skip them in the standard loop to avoid duplicates.
827
+ custom_names = {b["name"] for b in latest.get("custom_resources", [])}
828
+
829
  rows = []
830
  for r in resource_caps:
831
+ if r in custom_names:
832
+ continue # handled in the custom_resources loop below
833
  group = resource_to_group.get(r)
834
  unit = resource_unit_map.get(group)
835
  cap = resource_caps[r]
 
840
  "Resource": f"{r} ({unit})",
841
  "Available amount": cap,
842
  "Quantity used": used,
843
+ "Quantity remaining": max(0.0, cap - used),
844
+ "% used": (used / cap * 100) if cap > 0 else 0,
845
  })
846
 
847
  for batch in latest.get("custom_resources", []):
848
  r = batch["name"]
849
+ # Use resource_caps for the cap (includes expanded batch amounts).
850
+ cap = resource_caps.get(r, float(batch.get("amount", 0)))
851
+ if cap <= 0:
852
+ continue
853
  used = used_total.get(r, 0.0)
854
+ resource_to_group_local = get_resource_to_group()
855
+ group_name = resource_to_group_local.get(batch.get("group", ""))
856
+ unit = resource_unit_map.get(group_name, "?")
857
  rows.append({
858
+ "Resource": f"{r} ({unit})",
859
+ "Available amount": cap,
860
+ "Quantity used": used,
861
+ "Quantity remaining": max(0.0, cap - used),
862
+ "% used": (used / cap * 100) if cap > 0 else 0,
863
  })
864
 
865
  df = pd.DataFrame(rows).sort_values("% used", ascending=False)
 
866
  fig = px.bar(
867
  df, x="Resource", y=["Quantity used", "Quantity remaining"], barmode="stack",
868
  color_discrete_map={"Quantity used": CRRA_COLORS["green"], "Quantity remaining": CRRA_COLORS["off_white"]},
 
908
  f"CDR gain for +1 unit ({CO2_UNIT})": round(gain, 4),
909
  })
910
  df_sp = pd.DataFrame(sp_rows)
911
+ st.dataframe(df_sp, width='stretch', hide_index=True)
 
912
  register_chart_data("Resource sensitivity (CDR gain per +1 unit)", df_sp)
913
  else:
914
  st.info("No resources to analyse.")
 
940
  placeholder="Enter a scenario name",
941
  )
942
  with col_btn:
943
+ save_clicked = st.button("Save", width='stretch', icon=":material/save:")
944
+
945
+ if save_clicked:
946
+ name = st.session_state.get("scenario_name", "").strip()
947
+ if not name:
948
+ st.warning("Please enter a name.")
949
+ elif name in st.session_state.scenarios:
950
+ st.warning("Name already exists.")
951
+ else:
952
+ st.session_state.scenarios[name] = latest
953
+ st.success(f"Scenario '{name}' saved for comparison.")
954
 
955
  def export_scenario():
956
  """Render JSON and ZIP export download buttons for the currently saved scenario.
 
1122
  """
1123
  resource_to_unit = get_resource_to_unit()
1124
  st.markdown('<p class="crra-sub-heading">Export inputs</p>', unsafe_allow_html=True)
1125
+ st.markdown('<p class="caption">Export only the resource capabilities, method constraints and coefficients without results.</p>', unsafe_allow_html=True)
1126
 
1127
  suggested = st.session_state.get("excel_loaded_file", "").rsplit(".", 1)[0]
1128
  # Update when a new file is loaded
 
1132
 
1133
  resource_to_group = get_resource_to_group()
1134
 
1135
+ enabled_methods = st.session_state.get("enabled_methods", {})
1136
+
1137
+ def is_allowed(method, resource):
1138
+ return enabled_methods.get(resource, {}).get(method, True)
1139
+
1140
+ wb = openpyxl.Workbook()
1141
+ ws1 = wb.active
1142
+ ws1.title = "resource_caps"
1143
+ ws1.append(["Resource", "Unit", "Available Amount"])
1144
+ for r, v in st.session_state.resource_caps.items():
1145
+ ws1.append([r, resource_to_unit.get(r, ""), v])
1146
+
1147
+ ws2 = wb.create_sheet("constraints")
1148
+ ws2.append(["method", "active", "cap_type (percent or absolute)", "cap_value"])
1149
+ for m, c in st.session_state.constraints.items():
1150
+ ws2.append([m, c.get("active", True), c.get("cap_type", "percent"), c.get("cap_value", 100)])
1151
+
1152
+ # Coefficients — a disabled (method, resource) pair is always exported
1153
+ # as 0, regardless of whether the user ever opened it in tab3. This
1154
+ # keeps the export consistent with what the optimizer actually uses.
1155
+ ws3 = wb.create_sheet("coefficients")
1156
+ ws3.append(["method", "resource", "value", "secondary"])
1157
+ for m, resources in st.session_state.costs.items():
1158
+ for r, v in resources.items():
1159
+ value = 0.0 if not is_allowed(m, r) else v
1160
+ ws3.append([m, r, value, (m, r) in secondary_resources])
1161
+
1162
+ ws4 = wb.create_sheet("custom_resources")
1163
+ ws4.append(["name", "group", "amount", "unit", "method", "min", "median", "max"])
1164
+ for batch in st.session_state.get("custom_resources", []):
1165
+ unit = resource_unit_map.get(resource_to_group.get(batch["group"]))
1166
+ for method, c in batch["methods"].items():
1167
+ ws4.append([
1168
+ batch["name"],
1169
+ batch["group"],
1170
+ batch["amount"],
1171
+ unit,
1172
+ method,
1173
+ c["min"],
1174
+ c["median"],
1175
+ c["max"],
1176
+ ])
1177
+
1178
+ # Per-resource manual method allocation (objective 2) — which CDR
1179
+ # methods are allowed to use each resource batch.
1180
+ ws5 = wb.create_sheet("enabled_methods")
1181
+ ws5.append(["resource", "method", "enabled"])
1182
+ for r, methods in enabled_methods.items():
1183
+ for m, ok in methods.items():
1184
+ ws5.append([r, m, bool(ok)])
1185
+
1186
+ excel_buffer = io.BytesIO()
1187
+ wb.save(excel_buffer)
1188
+
1189
+ st.text_input(
1190
+ "File name",
1191
+ label_visibility="collapsed",
1192
+ key="input_file_name",
1193
+ placeholder="Enter a file name",
1194
+ )
1195
+ safe = st.session_state.get("input_file_name", "").strip() or suggested or "crra_inputs"
1196
+ st.download_button(
1197
+ "Export excel",
1198
+ data=excel_buffer.getvalue(),
1199
+ file_name=f"{safe}.xlsx",
1200
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1201
+ width='stretch',
1202
+ icon=":material/download:",
1203
+ )
 
 
 
 
 
 
 
 
 
 
 
1204
 
1205
  def export_all_charts():
1206
  """Render a download widget for all chart data registered in this session.
 
1223
  st.info("No charts have been generated yet in this session.")
1224
  return
1225
 
1226
+ base_name = st.text_input(
1227
+ "File name",
1228
+ value=None,
1229
+ key="charts_export_name",
1230
+ label_visibility="collapsed",
1231
+ placeholder="File name",
1232
+ help="The extension (.xlsx or .zip) is added automatically.",
1233
+ )
 
 
 
 
 
1234
 
1235
+ safe_name = (base_name or "").strip() or "charts_data"
1236
+
1237
+ xlsx_buffer = io.BytesIO()
1238
+ with pd.ExcelWriter(xlsx_buffer, engine="openpyxl") as writer:
1239
+ for name, df in registry.items():
1240
+ df.to_excel(writer, sheet_name=name[:31], index=False)
1241
+
1242
+ zip_buffer = io.BytesIO()
1243
+ with zipfile.ZipFile(zip_buffer, "w") as zf:
1244
+ for name, df in registry.items():
1245
+ safe_chart_name = name.replace(" ", "_").replace("/", "-").replace("(", "").replace(")", "")
1246
+ zf.writestr(f"{safe_chart_name}.csv", df.to_csv(index=False))
1247
 
1248
+ col_xlsx, col_zip = st.columns(2)
1249
+ with col_xlsx:
 
 
 
 
1250
  st.download_button(
1251
+ "Export Excel",
1252
+ data=xlsx_buffer.getvalue(),
1253
  file_name=f"{safe_name}.xlsx",
1254
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1255
+ width='stretch',
1256
+ icon=":material/download:",
1257
  )
1258
+ with col_zip:
 
 
 
 
 
 
1259
  st.download_button(
1260
+ "Export csv (ZIP)",
1261
+ data=zip_buffer.getvalue(),
1262
  file_name=f"{safe_name}.zip",
1263
  mime="application/zip",
1264
+ width='stretch',
1265
+ icon=":material/download:",
1266
  )
1267
 
1268
  def clear_all_data():
 
1391
  unsafe_allow_html=True,
1392
  )
1393
  run_clicked = st.button("Run Optimization", icon=":material/play_arrow:", key="btn-run-optimization", type="primary")
1394
+ run_msg_area = st.container()
1395
  panel_run_from_tabs()
1396
  panel_load_from_excel()
1397
 
 
1403
  panel_load_from_json_or_csv()
1404
 
1405
  if run_clicked:
1406
+ with run_msg_area:
1407
+ run_and_store_result()
1408
 
1409
  _no_results = '<p class="caption">Run the optimization first to see results here.</p>'
1410