CarlyneB commited on
Commit
87fce1c
·
1 Parent(s): 3182ce2

[FEAT] Objective 2: Custom resource batches with supplementary LP semantics

Browse files
assets/styles.css CHANGED
@@ -258,6 +258,20 @@ div.custom-corps {
258
  color: var(--red);
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  .stSidebar {
262
  background: var(--off-white) !important;
263
  }
@@ -297,6 +311,12 @@ section[data-testid="stExpander"] summary {
297
  letter-spacing: 0.05rem;
298
  }
299
 
 
 
 
 
 
 
300
  [class*="_dlg_existing_enable_"] p, [class*="label-input-"] p {
301
  font-family: var(--font);
302
  }
 
258
  color: var(--red);
259
  }
260
 
261
+ [class*="simple-btn-"] button {
262
+ color: var(--red);
263
+ }
264
+
265
+ [class*="simple-btn-"] button p{
266
+ text-transform: uppercase;
267
+ font-weight: bold;
268
+ }
269
+
270
+ [class*="simple-btn-"] button:hover{
271
+ color: #E5503F;
272
+ font-style: italic;
273
+ }
274
+
275
  .stSidebar {
276
  background: var(--off-white) !important;
277
  }
 
311
  letter-spacing: 0.05rem;
312
  }
313
 
314
+ .caption-input {
315
+ font-family: var(--font);
316
+ font-style: italic;
317
+ letter-spacing: 0.05rem;
318
+ }
319
+
320
  [class*="_dlg_existing_enable_"] p, [class*="label-input-"] p {
321
  font-family: var(--font);
322
  }
config/config.py CHANGED
@@ -65,7 +65,10 @@ method_order = sum(method_groups.values(), [])
65
 
66
  # secondary resources
67
  secondary_df = pd.read_csv("data/secondary_resources.csv")
68
- secondary_resources = set(zip(secondary_df["method"], secondary_df["resource"]))
 
 
 
69
 
70
  # tooltips
71
  resource_tooltips = {
 
65
 
66
  # secondary resources
67
  secondary_df = pd.read_csv("data/secondary_resources.csv")
68
+ secondary_resources = set(zip(
69
+ secondary_df["method"].str.strip(),
70
+ secondary_df["resource"].str.strip(),
71
+ ))
72
 
73
  # tooltips
74
  resource_tooltips = {
data/generate_cost_matrix.py CHANGED
@@ -89,8 +89,6 @@ def create_sliders_inputs():
89
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Non-forest biomass")
90
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Unspecified biomass")
91
 
92
-
93
-
94
  with open("data/cost_sliders.json", "w") as f:
95
  json.dump(cost_sliders, f, indent=2)
96
 
 
89
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Non-forest biomass")
90
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Unspecified biomass")
91
 
 
 
92
  with open("data/cost_sliders.json", "w") as f:
93
  json.dump(cost_sliders, f, indent=2)
94
 
optimization/optimization.py CHANGED
@@ -1,138 +1,277 @@
1
- import numpy as np
2
- from scipy.optimize import linprog
3
-
4
-
5
- def run_optimization(resource_caps, method_constraints, method_costs, custom_resources=None):
6
- """
7
- Run linear optimization to maximize CO₂ removal while respecting resource limits and method constraints.
8
-
9
- Args:
10
- resource_caps (dict): {resource: available_quantity}
11
- method_constraints (dict): {method: {"active": bool, "cap_type": str, "cap_value": float}}
12
- cap_type: "percent" → cap_value is a fraction of total (0 to 100)
13
- cap_type: "absolute" → cap_value is a hard upper bound in CO₂ units
14
- method_costs (dict): {method: {resource: usage_per_tCO2}}
15
- custom_resources (list | None): list of custom batch dicts: [{"name": str, "group": str, "amount": float, "methods": {method: {"min", "median", "max"}}}]
16
- If provided, each batch is injected as an extra resource row.
17
-
18
- Returns:
19
- (bool, dict): (success_flag, result_dict)
20
- result_dict includes "total_removed", "method_usage"
21
- """
22
- # ── Inject custom resource batches ────────────────────────────────────────
23
- # Work on shallow copies so callers are not mutated.
24
- resource_caps = dict(resource_caps)
25
- method_costs = {m: dict(v) for m, v in method_costs.items()}
26
-
27
- for batch in (custom_resources or []):
28
- r_key = batch["name"]
29
- resource_caps[r_key] = batch["amount"]
30
- for method, coefs in batch["methods"].items():
31
- if method in method_costs:
32
- method_costs[method][r_key] = coefs["median"]
33
- else:
34
- method_costs[method] = {r_key: coefs["median"]}
35
-
36
- # ── Debug custom resources ────────────────────────────────────────────────
37
- print(f"\n{'='*50}")
38
- print(f"custom_resources received: {len(custom_resources or [])} batch(es)")
39
- for batch in (custom_resources or []):
40
- r_key = batch["name"]
41
- print(f" → '{r_key}' | amount: {resource_caps.get(r_key)} | methods: {list(batch['methods'].keys())}")
42
- for method in batch["methods"]:
43
- coef = method_costs.get(method, {}).get(r_key)
44
- print(f" method_costs['{method}']['{r_key}'] = {coef}")
45
- print(f"Total resources in matrix: {len(resource_caps)}")
46
- print(f"Total resources in matrix: {resource_caps}")
47
- print(f"Resources: {list(resource_caps.keys())}")
48
- print(f"{'='*50}\n")
49
-
50
- # ── Standard optimisation setup ───────────────────────────────────────────
51
-
52
- # Only keep active methods
53
- methods = [m for m in method_costs if method_constraints.get(m, {}).get("active", True)]
54
- resources = list(resource_caps.keys())
55
-
56
- if not methods:
57
- return False, {"message": "No active methods."}
58
-
59
- # A matrix: each row is a resource, each column a method
60
- A_resource = np.array([
61
- [method_costs[m].get(r, 0.0) for m in methods]
62
- for r in resources
63
- ])
64
- b_resource = np.array([resource_caps[r] for r in resources])
65
-
66
- # Cap constraints per method
67
- A_share = []
68
- b_share = []
69
- for i, m in enumerate(methods):
70
- constraint = method_constraints.get(m, {})
71
- cap_type = constraint.get("cap_type", "percent")
72
- cap_value = constraint.get("cap_value", 100)
73
-
74
- # x_i <= cap_value
75
- if cap_type == "absolute":
76
- row = np.zeros(len(methods))
77
- row[i] = 1
78
- A_share.append(row)
79
- b_share.append(cap_value)
80
- # x_i <= cap_value/100 * sum(x) → x_i - cap_value/100 * sum(x) <= 0
81
- else:
82
- cap_ratio = cap_value / 100
83
- row = np.zeros(len(methods))
84
- row += -cap_ratio
85
- row[i] += 1
86
- A_share.append(row)
87
- b_share.append(0)
88
-
89
- # Combine constraints
90
- A_ub = np.vstack([A_resource, A_share])
91
- b_ub = np.concatenate([b_resource, b_share])
92
-
93
- # Objective: maximize sum(x_i), hence minimize -1 * sum(x_i)
94
- c = -1 * np.ones(len(methods))
95
- bounds = [(0, None)] * len(methods)
96
-
97
- try:
98
- result = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
99
- if result.success:
100
- # x = np.floor(result.x).astype(int)
101
- x = result.x
102
- total_removed = x.sum()
103
- method_usage = {m: x[i] for i, m in enumerate(methods) if x[i] > 0}
104
- resource_usage = {}
105
- for i, r in enumerate(resources):
106
- resource_usage[r] = {
107
- m: float(A_resource[i, j] * x[j])
108
- for j, m in enumerate(methods) if A_resource[i, j] * x[j] > 0
109
- }
110
-
111
- resource_used_total = {r: sum(resource_usage[r].values()) for r in resources}
112
- resource_remaining = {r: resource_caps[r] - resource_used_total[r] for r in resources}
113
-
114
- # Potentiel théorique par méthode (si seule, contrainte ressources + son propre cap)
115
- method_potential = {}
116
- for j, m in enumerate(methods):
117
- limits = []
118
- for i, r in enumerate(resources):
119
- coef = A_resource[i, j]
120
- if coef > 0:
121
- limits.append(resource_caps[r] / coef)
122
- constraint = method_constraints.get(m, {})
123
- if constraint.get("cap_type") == "absolute":
124
- limits.append(constraint.get("cap_value", float("inf")))
125
- method_potential[m] = float(min(limits)) if limits else 0.0
126
-
127
- return True, {
128
- "total_removed": total_removed,
129
- "method_usage": method_usage,
130
- "method_potential": method_potential,
131
- "resource_usage": resource_usage,
132
- "resource_used_total": resource_used_total,
133
- "resource_remaining": resource_remaining,
134
- }
135
- else:
136
- return False, {"message": result.message}
137
- except Exception as e:
138
- return False, {"message": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.optimize import linprog
3
+
4
+
5
+ def run_optimization(
6
+ resource_caps,
7
+ method_constraints,
8
+ method_costs,
9
+ custom_resources=None,
10
+ enabled_methods=None,
11
+ ):
12
+ """
13
+ Maximize total CO₂ removal subject to resource caps and method constraints.
14
+
15
+ Custom resource batches are treated as supplementary pools for their reference
16
+ resource (batch["group"]). Instead of adding a new AND-constraint per batch
17
+ (which would make the reference resource the bottleneck), the LP introduces
18
+ split variables z_{m,n} = CO₂ attributed to batch n for method m, so that
19
+ y_m = x_{m,standard} + z_{m,n} and each pool is constrained independently.
20
+
21
+ Args:
22
+ resource_caps {resource: available_quantity}
23
+ method_constraints {method: {"active": bool, "cap_type": str, "cap_value": float}}
24
+ method_costs {method: {resource: usage_per_MtCO2}}
25
+ custom_resources list of {"name", "group", "amount", "methods": {...}}
26
+ enabled_methods {resource: {method: bool}} — manual allocation overrides
27
+
28
+ Returns:
29
+ (success: bool, result: dict)
30
+ """
31
+
32
+ resource_caps = dict(resource_caps)
33
+ method_costs = {m: dict(v) for m, v in method_costs.items()}
34
+
35
+ # ── Apply manual allocation overrides ─────────────────────────────────────
36
+ enabled_methods = enabled_methods or {}
37
+ for method, resources in method_costs.items():
38
+ for resource in list(resources):
39
+ if not enabled_methods.get(resource, {}).get(method, True):
40
+ method_costs[method][resource] = 0.0
41
+
42
+ # ── Custom batch metadata ─────────────────────────────────────────────────
43
+ custom_batch_info = {} # {batch_name: {"group": ref, "amount": Q}}
44
+ for batch in (custom_resources or []):
45
+ custom_batch_info[batch["name"]] = {
46
+ "group": batch["group"],
47
+ "amount": float(batch["amount"]),
48
+ }
49
+ custom_names = set(custom_batch_info)
50
+ ref_to_batches = {} # {standard_resource: [batch_name, ...]}
51
+ for bname, info in custom_batch_info.items():
52
+ ref_to_batches.setdefault(info["group"], []).append(bname)
53
+
54
+ # ── Inject primary batch coefficients (fallback) ─────────────────────────
55
+ # Tab 3 pre-init may have set the coefficient to 0 if it classified the
56
+ # link as secondary (or if the key already existed at 0 from a prior run).
57
+ # For methods explicitly listed in batch["methods"] (primary dialog links),
58
+ # fall back to the batch median so the LP can actually use the resource.
59
+ for batch in (custom_resources or []):
60
+ bname = batch["name"]
61
+ for method, coefs in batch["methods"].items():
62
+ if method_costs.get(method, {}).get(bname, 0.0) == 0.0:
63
+ method_costs.setdefault(method, {})[bname] = coefs["median"]
64
+
65
+ # ── Debug custom resources ────────────────────────────────────────────────
66
+ print(f"\n{'='*50}")
67
+ print(f"custom_resources received: {len(custom_resources or [])} batch(es)")
68
+ for batch in (custom_resources or []):
69
+ r_key = batch["name"]
70
+ print(f" → '{r_key}' | amount: {resource_caps.get(r_key)} | methods: {list(batch['methods'].keys())}")
71
+ for method in batch["methods"]:
72
+ coef = method_costs.get(method, {}).get(r_key)
73
+ print(f" method_costs['{method}']['{r_key}'] = {coef}")
74
+ print(f"Total resources in matrix: {len(resource_caps)}")
75
+ print(f"Total resources in matrix: {resource_caps}")
76
+ print(f"Resources: {list(resource_caps.keys())}")
77
+ print(f"{'='*50}\n")
78
+
79
+ # ── Active method list ────────────────────────────────────────────────────
80
+ methods = [m for m in method_costs if method_constraints.get(m, {}).get("active", True)]
81
+ M = len(methods)
82
+ method_idx = {m: j for j, m in enumerate(methods)}
83
+ if not M:
84
+ return False, {"message": "No active methods."}
85
+
86
+ # ── Split-variable list ───────────────────────────────────────────────────
87
+ # z_{m,n} exists when BOTH c_s[m] > 0 AND c_n[m] > 0.
88
+ # It represents CO₂ attributed to custom pool n for method m, substituting
89
+ # for an equal amount of output that would otherwise consume standard pool s.
90
+ #
91
+ # Guard: if the standard resource cap is 0 (user left it unset), the custom
92
+ # batch is also inactive — a batch supplements an existing resource, it doesn't
93
+ # enable a resource type that the user hasn't allocated.
94
+ z_pairs = []
95
+ for bname, info in custom_batch_info.items():
96
+ ref = info["group"]
97
+ if resource_caps.get(ref, 0.0) <= 0:
98
+ continue # standard resource unavailable → custom batch inactive too
99
+ for m in methods:
100
+ c_s = method_costs.get(m, {}).get(ref, 0.0)
101
+ c_n = method_costs.get(m, {}).get(bname, 0.0)
102
+ if c_s > 0 and c_n > 0:
103
+ z_pairs.append((m, bname))
104
+
105
+ Z = len(z_pairs)
106
+ z_idx = {pair: M + i for i, pair in enumerate(z_pairs)}
107
+ N = M + Z # total LP variables: [y_0..y_{M-1}, z_0..z_{Z-1}]
108
+
109
+ print(f"z_pairs ({Z}): {z_pairs}")
110
+
111
+ # ── Resource categorisation ───────────────────────────────────────────────
112
+ standard_resources = [r for r in resource_caps if r not in custom_names]
113
+ resources_with_batches = [r for r in standard_resources if r in ref_to_batches]
114
+ resources_no_batches = [r for r in standard_resources if r not in ref_to_batches]
115
+
116
+ # ── Build LP constraint matrix ────────────────────────────────────────────
117
+ A_rows, b_rows = [], []
118
+
119
+ # (A) Standard resources with NO custom batch substitutes — unchanged rows
120
+ for r in resources_no_batches:
121
+ row = np.zeros(N)
122
+ for j, m in enumerate(methods):
123
+ row[j] = method_costs.get(m, {}).get(r, 0.0)
124
+ if np.any(row[:M] != 0):
125
+ A_rows.append(row)
126
+ b_rows.append(resource_caps[r])
127
+
128
+ # (B) Standard resources that DO have custom batch substitutes
129
+ # Constraint: Σ_m c_s[m] * (y_m - Σ_n z_{m,n}) ≤ Q_s
130
+ for r in resources_with_batches:
131
+ row = np.zeros(N)
132
+ for j, m in enumerate(methods):
133
+ c_s_m = method_costs.get(m, {}).get(r, 0.0)
134
+ row[j] = c_s_m
135
+ for bname in ref_to_batches.get(r, []):
136
+ if (m, bname) in z_idx:
137
+ row[z_idx[(m, bname)]] -= c_s_m # subtract z contribution
138
+ if np.any(row != 0):
139
+ A_rows.append(row)
140
+ b_rows.append(resource_caps[r])
141
+
142
+ # (C) Custom batch capacity constraints
143
+ # For substitutable pairs (m, n) in z_idx → c_n[m] * z_{m,n}
144
+ # For non-substitutable (c_s = 0, c_n > 0) → c_n[m] * y_m (method fully
145
+ # relies on this batch since it has no access to the standard resource)
146
+ # Skip entirely when the standard resource cap is 0 (batch is inactive).
147
+ for bname, info in custom_batch_info.items():
148
+ ref = info["group"]
149
+ if resource_caps.get(ref, 0.0) <= 0:
150
+ continue
151
+ row = np.zeros(N)
152
+ for j, m in enumerate(methods):
153
+ c_n_m = method_costs.get(m, {}).get(bname, 0.0)
154
+ if c_n_m <= 0:
155
+ continue
156
+ c_s_m = method_costs.get(m, {}).get(ref, 0.0)
157
+ if c_s_m > 0 and (m, bname) in z_idx:
158
+ row[z_idx[(m, bname)]] = c_n_m
159
+ else:
160
+ row[j] = c_n_m # entire y_m draws from this batch
161
+ if np.any(row != 0):
162
+ A_rows.append(row)
163
+ b_rows.append(info["amount"])
164
+
165
+ # (D) Coupling constraints per (method, standard-resource-type)
166
+ # Ensure Σ_{n: ref(n)=s} z_{m,n} ≤ y_m so standard usage stays ≥ 0
167
+ coupling_done = set()
168
+ for (m, bname) in z_pairs:
169
+ ref = custom_batch_info[bname]["group"]
170
+ key = (m, ref)
171
+ if key in coupling_done:
172
+ continue
173
+ coupling_done.add(key)
174
+ j = method_idx[m]
175
+ row = np.zeros(N)
176
+ row[j] = -1.0
177
+ for bn in ref_to_batches.get(ref, []):
178
+ if (m, bn) in z_idx:
179
+ row[z_idx[(m, bn)]] = 1.0
180
+ A_rows.append(row)
181
+ b_rows.append(0.0)
182
+
183
+ # (E) Method cap constraints (on y_m)
184
+ for j, m in enumerate(methods):
185
+ constraint = method_constraints.get(m, {})
186
+ cap_type = constraint.get("cap_type", "percent")
187
+ cap_value = constraint.get("cap_value", 100)
188
+ if cap_type == "absolute":
189
+ row = np.zeros(N)
190
+ row[j] = 1.0
191
+ A_rows.append(row)
192
+ b_rows.append(cap_value)
193
+ else:
194
+ cap_ratio = cap_value / 100.0
195
+ row = np.zeros(N)
196
+ row[:M] -= cap_ratio
197
+ row[j] += 1.0
198
+ A_rows.append(row)
199
+ b_rows.append(0.0)
200
+
201
+ A_ub = np.array(A_rows) if A_rows else np.empty((0, N))
202
+ b_ub = np.array(b_rows)
203
+
204
+ # Objective: maximize Σ y_m → minimize -Σ y_m (z variables don't add to objective)
205
+ c_obj = np.zeros(N)
206
+ c_obj[:M] = -1.0
207
+ bounds = [(0, None)] * N
208
+
209
+ # ── Solve ─────────────────────────────────────────────────────────────────
210
+ try:
211
+ result = linprog(c_obj, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
212
+ if not result.success:
213
+ return False, {"message": result.message}
214
+
215
+ sol = result.x
216
+ y = sol[:M] # total CO₂ per method
217
+
218
+ total_removed = float(y.sum())
219
+ method_usage = {methods[j]: float(y[j]) for j in range(M) if y[j] > 1e-9}
220
+
221
+ # ── Resource usage ────────────────────────────────────────────────────
222
+ all_resource_names = standard_resources + list(custom_names)
223
+ resource_usage = {r: {} for r in all_resource_names}
224
+
225
+ # Standard resources without substitutes
226
+ for r in resources_no_batches:
227
+ for j, m in enumerate(methods):
228
+ used = method_costs.get(m, {}).get(r, 0.0) * y[j]
229
+ if used > 1e-9:
230
+ resource_usage[r][m] = float(used)
231
+
232
+ # Standard resources with substitutes: usage = c_s * (y_m - Σ z_{m,n})
233
+ for r in resources_with_batches:
234
+ for j, m in enumerate(methods):
235
+ c_s_m = method_costs.get(m, {}).get(r, 0.0)
236
+ z_sum = sum(
237
+ sol[z_idx[(m, bn)]]
238
+ for bn in ref_to_batches.get(r, [])
239
+ if (m, bn) in z_idx
240
+ )
241
+ used = c_s_m * (y[j] - z_sum)
242
+ if used > 1e-9:
243
+ resource_usage[r][m] = float(used)
244
+
245
+ # Custom batches
246
+ for bname, info in custom_batch_info.items():
247
+ ref = info["group"]
248
+ for j, m in enumerate(methods):
249
+ c_n_m = method_costs.get(m, {}).get(bname, 0.0)
250
+ if c_n_m <= 0:
251
+ continue
252
+ c_s_m = method_costs.get(m, {}).get(ref, 0.0)
253
+ if c_s_m > 0 and (m, bname) in z_idx:
254
+ used = c_n_m * sol[z_idx[(m, bname)]]
255
+ else:
256
+ used = c_n_m * y[j]
257
+ if used > 1e-9:
258
+ resource_usage[bname][m] = float(used)
259
+
260
+ resource_used_total = {r: sum(v.values()) for r, v in resource_usage.items()}
261
+
262
+ all_caps = {**resource_caps,
263
+ **{bn: info["amount"] for bn, info in custom_batch_info.items()}}
264
+ resource_remaining = {r: all_caps.get(r, 0.0) - resource_used_total.get(r, 0.0)
265
+ for r in all_caps}
266
+ print(method_costs)
267
+
268
+ return True, {
269
+ "total_removed": total_removed,
270
+ "method_usage": method_usage,
271
+ "resource_usage": resource_usage,
272
+ "resource_used_total":resource_used_total,
273
+ "resource_remaining": resource_remaining,
274
+ }
275
+
276
+ except Exception as e:
277
+ return False, {"message": str(e)}
tabs/tab1_inputs.py CHANGED
@@ -2,7 +2,7 @@ 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)
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
@@ -16,6 +16,8 @@ def standard_resource_names():
16
  def init_custom_resources():
17
  if "custom_resources" not in st.session_state:
18
  st.session_state.custom_resources = []
 
 
19
  for batch in st.session_state.custom_resources:
20
  try:
21
  batch["amount"] = float(batch["amount"])
@@ -32,12 +34,11 @@ def init_custom_resources():
32
  # ─── Dialog: add / edit a custom resource batch ───────────────────────────────
33
 
34
  @st.dialog("Add a new resource", width="large")
35
- def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
36
 
37
  if edit_index is not None and not st.session_state.get(f"_dlg_initialized_{edit_index}"):
38
  for key in list(st.session_state.keys()):
39
- if key.startswith(("_dlg_existing_enable_", "_dlg_edit_existing_",
40
- "_dlg_min_existing_", "_dlg_med_existing_", "_dlg_max_existing_",
41
  "_dlg_new_chk_", "_dlg_new_min_", "_dlg_new_med_", "_dlg_new_max_")):
42
  del st.session_state[key]
43
  st.session_state[f"_dlg_initialized_{edit_index}"] = True
@@ -54,7 +55,18 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
54
  for group, resources in resource_groups.items()
55
  for r in resources
56
  }
57
-
 
 
 
 
 
 
 
 
 
 
 
58
  st.markdown('<p class="dialog-title">1 — Name & group</p>', unsafe_allow_html=True)
59
 
60
  col_name, col_group = st.columns(2)
@@ -67,12 +79,13 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
67
  key="label-input-name"
68
  )
69
  with col_group:
70
- default_group_idx = resource_order.index(existing["group"]) if existing.get("group") in resource_order else 0
71
  batch_group = st.selectbox(
72
  "Resource group *",
73
- options=resource_order,
74
  index=default_group_idx,
75
  help="Determines the physical unit of this resource.",
 
76
  key="label-input-group"
77
  )
78
 
@@ -96,28 +109,40 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
96
 
97
  method_configs = {}
98
 
99
- # Methods linked to this resource grouped by CDR group
100
- related_methods = {}
101
  for cdr_method, resource in cost_sliders.items():
102
  for name, coeff in resource.items():
103
  if batch_group == strip_unit_suffix(name):
104
- related_methods[cdr_method] = {
105
  "min": coeff["min"],
106
  "median": coeff["median"],
107
  "max": coeff["max"],
108
  }
109
 
110
- # group_name -> methods
111
- related_by_group = {}
 
 
 
 
 
 
 
112
 
113
- for m, coeff in related_methods.items():
 
 
114
  method_group = next(
115
  (g for g, methods in method_groups.items() if m in methods),
116
  "Other"
117
  )
118
- related_by_group.setdefault(method_group, {})[m] = coeff
 
 
 
119
 
120
- for method_group, methods in related_by_group.items():
121
  with st.expander(f"**{method_group}**", expanded=False):
122
  for m, coeff in methods.items():
123
 
@@ -134,72 +159,25 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
134
  if not enabled:
135
  continue
136
 
137
- if f"_dlg_edit_existing_{m}" not in st.session_state:
138
- saved = existing.get("methods", {}).get(m, {})
139
- default = coeff
140
- coeffs_differ = any(
141
- abs(float(saved.get(k, default[k])) - float(default[k])) > 1e-9
142
- for k in ("min", "median", "max")
143
- ) if saved else False
144
- st.session_state[f"_dlg_edit_existing_{m}"] = coeffs_differ
145
-
146
- edit = st.checkbox(
147
- "Edit coefficients (will overwrite default values)",
148
- key=f"_dlg_edit_existing_{m}",
149
  )
 
 
 
 
 
 
 
 
 
150
 
151
- if edit:
152
- saved = existing.get("methods", {}).get(m, coeff)
153
- st.warning(
154
- "Editing this method will overwrite the default coefficient values.",
155
- icon="⚠️"
156
- )
157
- col_min, col_med, col_max = st.columns(3)
158
- with col_min:
159
- v_min = st.number_input(
160
- "Min",
161
- min_value=0.0,
162
- value=float(saved.get("min", coeff["min"])),
163
- format="%.4f",
164
- key=f"_dlg_min_existing_{m}",
165
- )
166
-
167
- with col_med:
168
- v_med = st.number_input(
169
- "Median ★",
170
- min_value=0.0,
171
- value=float(saved.get("median", coeff["median"])),
172
- format="%.4f",
173
- key=f"_dlg_med_existing_{m}",
174
- )
175
-
176
- with col_max:
177
- v_max = st.number_input(
178
- "Max",
179
- min_value=0.0,
180
- value=float(saved.get("max", coeff["max"])),
181
- format="%.4f",
182
- key=f"_dlg_max_existing_{m}",
183
- )
184
-
185
- method_configs[m] = {
186
- "min": v_min,
187
- "median": v_med,
188
- "max": v_max,
189
- }
190
-
191
- else:
192
- st.dataframe(
193
- pd.DataFrame([{
194
- "Min": coeff["min"],
195
- "Median": coeff["median"],
196
- "Max": coeff["max"],
197
- }]),
198
- hide_index=True,
199
- use_container_width=True,
200
- )
201
-
202
- method_configs[m] = coeff.copy()
203
  # -------------------------------------------------
204
  # Part 4 : new/custom methods only
205
  # -------------------------------------------------
@@ -207,7 +185,7 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
207
  st.markdown('<p class="dialog-title">4 — Add additional CDR methods</p>', unsafe_allow_html=True)
208
  st.markdown(f'<p class="caption">Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.</p>', unsafe_allow_html=True)
209
 
210
- already_used = set(related_methods.keys())
211
 
212
  for group_name, methods in method_groups.items():
213
  available_methods = [
@@ -267,8 +245,8 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
267
  errors.append(f"**{batch_name}** already exists. Choose a different name.")
268
  if batch_amount <= 0:
269
  errors.append("Available amount must be greater than 0.")
270
- if not method_configs:
271
- errors.append("Select at least one CDR method.")
272
  for m, c in method_configs.items():
273
  if c["min"] > c["max"]:
274
  errors.append(f"**{m}**: min ({c['min']}) cannot be greater than max ({c['max']}).")
@@ -288,12 +266,16 @@ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None):
288
  "name": batch_name.strip(),
289
  "group": batch_group,
290
  "amount": float(batch_amount), # always store as float
 
291
  "methods": method_configs,
292
  }
293
  if edit_index is not None:
294
  st.session_state.custom_resources[edit_index] = batch
 
295
  else:
 
296
  st.session_state.custom_resources.append(batch)
 
297
  st.session_state.pop("latest_result", None)
298
  st.rerun()
299
 
@@ -337,10 +319,13 @@ def _parse_custom_excel(file) -> list:
337
  }
338
  for _, row in df_b.iterrows()
339
  }
 
 
340
  batches.append({
341
  "name": str(bname),
342
- "group": str(df_b["group"].iloc[0]),
343
  "amount": float(df_b["amount"].iloc[0]),
 
344
  "methods": methods,
345
  })
346
  return batches
@@ -401,116 +386,93 @@ def dialog_load_custom_batch():
401
 
402
  # ─── Custom resources panel ───────────────────────────────────────────────────
403
 
404
- def panel_custom_resources(cost_sliders):
405
- init_custom_resources()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- st.markdown('<p class="crra-sub-heading">➕ Custom resource</p>', unsafe_allow_html=True)
408
- st.markdown('<p class="caption"> Add sub-batches for any resource group and manually assign them to specific CDR methods.</p>', unsafe_allow_html=True)
409
 
410
- col = st.container(key="container-btn")
 
 
411
 
412
- with col:
 
 
 
 
 
 
 
413
  if st.button("New resource", type="tertiary", icon=":material/add:", key="btn-new-resource"):
414
  dialog_add_custom_resource(cost_sliders)
415
  if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load"):
416
  dialog_load_custom_batch()
417
-
418
- resource_to_group = {
419
- r: group
420
- for group, resources in resource_groups.items()
421
- for r in resources
422
- }
423
-
424
- batches = st.session_state.custom_resources
425
- if not batches:
426
- st.info("No new resources have been defined yet. Click **+ New resource** to get started.")
427
- return
428
-
429
- st.markdown(f"**{len(batches)} new resource(s)**")
430
- for i, batch in enumerate(batches):
431
-
432
- group = resource_to_group.get(batch["group"])
433
- unit = resource_unit_map.get(group, "?")
434
-
435
- batch["unit"] = unit
436
- n_methods = len(batch["methods"])
437
- amount_val = float(batch["amount"])
438
-
439
- with st.expander(
440
- f"🆕 **{batch['name']}** ({batch['group']}) — CDR methods: **{n_methods}**",
441
- expanded=False,
442
- ):
443
- st.markdown(f"**{batch['name']}** ({unit})")
444
-
445
- key_amount = f"custom_amount_{i}"
446
- # Initialise widget state only if not already set (avoids overwrite on rerun)
447
- if key_amount not in st.session_state:
448
- st.session_state[key_amount] = amount_val
449
-
450
- col_amount, _, _, _ = st.columns(4)
451
- with col_amount:
452
- new_amount = st.number_input(
453
- "Available amount",
454
- label_visibility="collapsed",
455
- min_value=0.0,
456
- format="%.2f",
457
- key=key_amount,
458
- )
459
- # Write back as float — never as a formatted string
460
- if new_amount != amount_val:
461
- st.session_state.custom_resources[i]["amount"] = float(new_amount)
462
- st.session_state.pop("latest_result", None)
463
-
464
- # Coefficients table
465
- col_min = f"Min ({unit}/MtCO₂)"
466
- col_med = f"Median ({unit}/MtCO₂)"
467
- col_max = f"Max ({unit}/MtCO₂)"
468
- rows = [
469
- {
470
- "Method": m,
471
- col_min: float(c["min"]),
472
- col_med: float(c["median"]),
473
- col_max: float(c["max"]),
474
- }
475
- for m, c in batch["methods"].items()
476
- ]
477
- st.dataframe(
478
- pd.DataFrame(rows),
479
- use_container_width=True,
480
- hide_index=True,
481
- key=f"batch_editor_{i}",
482
- column_config={
483
- "Method": st.column_config.TextColumn(disabled=True),
484
- col_min: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
485
- col_med: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
486
- col_max: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
487
- },
488
  )
489
 
490
- col_edit, col_del = st.columns([1, 1])
491
- with col_edit:
492
- if st.button("Edit", key=f"edit_batch_{i}", use_container_width=True, icon=":material/edit:"):
493
- dialog_add_custom_resource(cost_sliders, edit_index=i)
494
- with col_del:
495
- if st.button("Delete", key=f"del_batch_{i}", use_container_width=True,
496
- type="primary", icon=":material/delete:"):
497
- st.session_state.custom_resources.pop(i)
498
- st.session_state.pop("latest_result", None)
499
- st.rerun()
500
-
501
- # Export all batches as a single Excel file
502
- _, col_save_custom = st.columns(2)
503
- with col_save_custom:
504
- st.download_button(
505
- "Download new resources",
506
- data=to_excel_bytes(batches),
507
- file_name="new_resources.xlsx",
508
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
509
- icon=":material/download:",
510
- # use_container_width=True,
511
- key="download_button_save_custom"
512
- )
513
-
514
 
515
  # ─── Main render ──────────────────────────────────────────────────────────────
516
 
@@ -544,6 +506,7 @@ def render_tab(cost_sliders):
544
  for r in st.session_state.resource_caps:
545
  st.session_state.resource_caps[r] = 0.0
546
  st.session_state.custom_resources = []
 
547
  st.success("All resource values reset to 0.")
548
  st.rerun()
549
 
@@ -601,18 +564,146 @@ def render_tab(cost_sliders):
601
 
602
  # Standard resource inputs
603
  st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  for group, rlist in resource_groups.items():
605
  unit = resource_unit_map.get(group, "")
606
  with st.expander(f"**{group}** ({unit})", expanded=True):
607
- cols = st.columns(4)
 
 
608
  for i, r in enumerate(rlist):
 
609
  tooltip = resource_tooltips.get(r, None)
610
  name_clean = r.split(" (")[0]
611
- with cols[i % 4]:
 
 
612
  render_resource_input(name_clean, r, tooltip=tooltip)
613
 
614
- # Custom resource batches panel
615
- panel_custom_resources(cost_sliders)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
  # Summary table
618
  st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True)
@@ -633,12 +724,6 @@ def render_tab(cost_sliders):
633
  for resource, value in df_caps["Available Amount"].items()
634
  ]
635
 
636
- resource_to_group = {
637
- r: group
638
- for group, resources in resource_groups.items()
639
- for r in resources
640
- }
641
-
642
  custom_rows = []
643
  for batch in st.session_state.get("custom_resources", []):
644
  group = resource_to_group.get(batch["group"])
 
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
 
16
  def init_custom_resources():
17
  if "custom_resources" not in st.session_state:
18
  st.session_state.custom_resources = []
19
+ if "enabled_methods" not in st.session_state:
20
+ st.session_state.enabled_methods = {}
21
  for batch in st.session_state.custom_resources:
22
  try:
23
  batch["amount"] = float(batch["amount"])
 
34
  # ─── Dialog: add / edit a custom resource batch ───────────────────────────────
35
 
36
  @st.dialog("Add a new resource", width="large")
37
+ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None, preset_resources: list | None = None):
38
 
39
  if edit_index is not None and not st.session_state.get(f"_dlg_initialized_{edit_index}"):
40
  for key in list(st.session_state.keys()):
41
+ if key.startswith(("_dlg_existing_enable_",
 
42
  "_dlg_new_chk_", "_dlg_new_min_", "_dlg_new_med_", "_dlg_new_max_")):
43
  del st.session_state[key]
44
  st.session_state[f"_dlg_initialized_{edit_index}"] = True
 
55
  for group, resources in resource_groups.items()
56
  for r in resources
57
  }
58
+
59
+ # If opened from a specific resource-group expander ("+ Add resource" inline
60
+ # button), restrict the reference-resource choice to that group's resources
61
+ # only — the user picked the group by clicking that button.
62
+ group_options = preset_resources if preset_resources else resource_order
63
+ if preset_resources:
64
+ physical_group = resource_to_group.get(preset_resources[0], "")
65
+ st.markdown(
66
+ f'<p class="caption-input">Adding a new resource batch in the <strong>{physical_group}</strong> group.</p>',
67
+ unsafe_allow_html=True,
68
+ )
69
+
70
  st.markdown('<p class="dialog-title">1 — Name & group</p>', unsafe_allow_html=True)
71
 
72
  col_name, col_group = st.columns(2)
 
79
  key="label-input-name"
80
  )
81
  with col_group:
82
+ default_group_idx = group_options.index(existing["group"]) if existing.get("group") in group_options else 0
83
  batch_group = st.selectbox(
84
  "Resource group *",
85
+ options=group_options,
86
  index=default_group_idx,
87
  help="Determines the physical unit of this resource.",
88
+ disabled=bool(preset_resources) and len(group_options) == 1,
89
  key="label-input-group"
90
  )
91
 
 
109
 
110
  method_configs = {}
111
 
112
+ # All methods linked to this resource (primary + secondary)
113
+ all_related_methods = {}
114
  for cdr_method, resource in cost_sliders.items():
115
  for name, coeff in resource.items():
116
  if batch_group == strip_unit_suffix(name):
117
+ all_related_methods[cdr_method] = {
118
  "min": coeff["min"],
119
  "median": coeff["median"],
120
  "max": coeff["max"],
121
  }
122
 
123
+ # Split into primary (selectable) and secondary (auto-linked in Tab 3)
124
+ primary_related = {
125
+ m: coeff for m, coeff in all_related_methods.items()
126
+ if (m, batch_group) not in secondary_resources
127
+ }
128
+ secondary_related = {
129
+ m: coeff for m, coeff in all_related_methods.items()
130
+ if (m, batch_group) in secondary_resources
131
+ }
132
 
133
+ # group_name -> primary methods only
134
+ primary_by_group = {}
135
+ for m, coeff in primary_related.items():
136
  method_group = next(
137
  (g for g, methods in method_groups.items() if m in methods),
138
  "Other"
139
  )
140
+ primary_by_group.setdefault(method_group, {})[m] = coeff
141
+
142
+ if not primary_by_group:
143
+ st.markdown('<p class="caption">No primary CDR methods use this resource directly.</p>', unsafe_allow_html=True)
144
 
145
+ for method_group, methods in primary_by_group.items():
146
  with st.expander(f"**{method_group}**", expanded=False):
147
  for m, coeff in methods.items():
148
 
 
159
  if not enabled:
160
  continue
161
 
162
+ st.dataframe(
163
+ pd.DataFrame([{
164
+ "Min": coeff["min"],
165
+ "Median": coeff["median"],
166
+ "Max": coeff["max"],
167
+ }]),
168
+ hide_index=True,
169
+ use_container_width=True,
 
 
 
 
170
  )
171
+ method_configs[m] = coeff.copy()
172
+
173
+ if secondary_related:
174
+ names = "\n".join(f"- **{m}**" for m in secondary_related)
175
+ st.info(
176
+ f"""The following methods use this resource as **secondary** and may be assigned later in the **Resource Coefficients** tab:\n{names}
177
+ """,
178
+ icon="ℹ️",
179
+ )
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  # -------------------------------------------------
182
  # Part 4 : new/custom methods only
183
  # -------------------------------------------------
 
185
  st.markdown('<p class="dialog-title">4 — Add additional CDR methods</p>', unsafe_allow_html=True)
186
  st.markdown(f'<p class="caption">Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.</p>', unsafe_allow_html=True)
187
 
188
+ already_used = set(all_related_methods.keys())
189
 
190
  for group_name, methods in method_groups.items():
191
  available_methods = [
 
245
  errors.append(f"**{batch_name}** already exists. Choose a different name.")
246
  if batch_amount <= 0:
247
  errors.append("Available amount must be greater than 0.")
248
+ # if not method_configs:
249
+ # errors.append("Select at least one CDR method.")
250
  for m, c in method_configs.items():
251
  if c["min"] > c["max"]:
252
  errors.append(f"**{m}**: min ({c['min']}) cannot be greater than max ({c['max']}).")
 
266
  "name": batch_name.strip(),
267
  "group": batch_group,
268
  "amount": float(batch_amount), # always store as float
269
+ "unit": resource_unit_map.get(resource_to_group.get(batch_group), "?"),
270
  "methods": method_configs,
271
  }
272
  if edit_index is not None:
273
  st.session_state.custom_resources[edit_index] = batch
274
+ st.session_state.pop(f"custom_amount_{edit_index}", None)
275
  else:
276
+ new_idx = len(st.session_state.custom_resources)
277
  st.session_state.custom_resources.append(batch)
278
+ st.session_state.pop(f"custom_amount_{new_idx}", None)
279
  st.session_state.pop("latest_result", None)
280
  st.rerun()
281
 
 
319
  }
320
  for _, row in df_b.iterrows()
321
  }
322
+ group_name = str(df_b["group"].iloc[0])
323
+ _r2g = {r: g for g, rs in resource_groups.items() for r in rs}
324
  batches.append({
325
  "name": str(bname),
326
+ "group": group_name,
327
  "amount": float(df_b["amount"].iloc[0]),
328
+ "unit": resource_unit_map.get(_r2g.get(group_name), "?"),
329
  "methods": methods,
330
  })
331
  return batches
 
386
 
387
  # ─── Custom resources panel ───────────────────────────────────────────────────
388
 
389
+ def _render_custom_batch_inline(global_idx, batch, cost_sliders, unit):
390
+ """Render a single custom batch inside its resource-group expander."""
391
+ n_methods = len(batch["methods"])
392
+ amount_val = float(batch["amount"])
393
+ batch["unit"] = unit # keep in sync for Excel export
394
+
395
+ st.markdown(f"🆕 **{batch['name']}** — CDR methods: **{n_methods}**")
396
+ with st.container(border=True):
397
+ key_amount = f"custom_amount_{global_idx}"
398
+ if key_amount not in st.session_state:
399
+ st.session_state[key_amount] = amount_val
400
+
401
+ col_amount, _, _, _ = st.columns(4)
402
+ with col_amount:
403
+ new_amount = st.number_input(
404
+ f"Available amount ({unit})",
405
+ min_value=0.0,
406
+ format="%.2f",
407
+ key=key_amount,
408
+ )
409
+ if new_amount != amount_val:
410
+ st.session_state.custom_resources[global_idx]["amount"] = float(new_amount)
411
+ st.session_state.pop("latest_result", None)
412
+
413
+ col_min = f"Min ({unit}/MtCO₂)"
414
+ col_med = f"Median ({unit}/MtCO₂)"
415
+ col_max = f"Max ({unit}/MtCO₂)"
416
+ rows = [
417
+ {
418
+ "Method": m,
419
+ col_min: float(c["min"]),
420
+ col_med: float(c["median"]),
421
+ col_max: float(c["max"]),
422
+ }
423
+ for m, c in batch["methods"].items()
424
+ ]
425
+ st.dataframe(
426
+ pd.DataFrame(rows),
427
+ use_container_width=True,
428
+ hide_index=True,
429
+ key=f"batch_editor_{global_idx}",
430
+ column_config={
431
+ "Method": st.column_config.TextColumn(disabled=True),
432
+ col_min: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
433
+ col_med: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
434
+ col_max: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
435
+ },
436
+ )
437
+
438
+ col_edit, col_del = st.columns([1, 1])
439
+ with col_edit:
440
+ if st.button("Edit", key=f"edit_batch_{global_idx}", use_container_width=True, icon=":material/edit:"):
441
+ dialog_add_custom_resource(cost_sliders, edit_index=global_idx)
442
+ with col_del:
443
+ if st.button("Delete", key=f"del_batch_{global_idx}", use_container_width=True,
444
+ type="primary", icon=":material/delete:"):
445
+ st.session_state.custom_resources.pop(global_idx)
446
+ st.session_state.pop("latest_result", None)
447
+ st.rerun()
448
 
 
 
449
 
450
+ def panel_custom_resources_controls(cost_sliders):
451
+ """Buttons to add / load / download custom resource batches (no batch display here)."""
452
+ init_custom_resources()
453
 
454
+ st.markdown('<p class="crra-sub-heading">➕ New resource</p>', unsafe_allow_html=True)
455
+ st.markdown(
456
+ '<p class="caption"> Add sub-batches for any resource group and manually assign them to specific CDR methods.</p>',
457
+ unsafe_allow_html=True,
458
+ )
459
+ batches = st.session_state.custom_resources
460
+
461
+ with st.container(key="container-btn"):
462
  if st.button("New resource", type="tertiary", icon=":material/add:", key="btn-new-resource"):
463
  dialog_add_custom_resource(cost_sliders)
464
  if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load"):
465
  dialog_load_custom_batch()
466
+ if batches:
467
+ st.download_button(
468
+ "Download new resources",
469
+ data=to_excel_bytes(batches),
470
+ file_name="new_resources.xlsx",
471
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
472
+ icon=":material/download:",
473
+ key="download_button_save_custom",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  )
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
  # ─── Main render ──────────────────────────────────────────────────────────────
478
 
 
506
  for r in st.session_state.resource_caps:
507
  st.session_state.resource_caps[r] = 0.0
508
  st.session_state.custom_resources = []
509
+ st.session_state.enabled_methods = {}
510
  st.success("All resource values reset to 0.")
511
  st.rerun()
512
 
 
564
 
565
  # Standard resource inputs
566
  st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True)
567
+
568
+ # Pré-calcul : pour chaque méthode, la liste de ses ressources primaires (non secondaires)
569
+ method_to_primary_resources = {}
570
+ for cdr_method, resource_coeffs in cost_sliders.items():
571
+ for name in resource_coeffs:
572
+ r_clean = strip_unit_suffix(name)
573
+ if (cdr_method, r_clean) not in secondary_resources:
574
+ method_to_primary_resources.setdefault(cdr_method, []).append(r_clean)
575
+
576
+ for r in method_to_primary_resources:
577
+ pass # no-op, kept for clarity of intent below
578
+
579
+ resource_to_group = {
580
+ r: g
581
+ for g, rlist in resource_groups.items()
582
+ for r in rlist
583
+ }
584
+
585
  for group, rlist in resource_groups.items():
586
  unit = resource_unit_map.get(group, "")
587
  with st.expander(f"**{group}** ({unit})", expanded=True):
588
+
589
+ cols = st.columns(3)
590
+
591
  for i, r in enumerate(rlist):
592
+
593
  tooltip = resource_tooltips.get(r, None)
594
  name_clean = r.split(" (")[0]
595
+
596
+ with cols[i % 3]:
597
+
598
  render_resource_input(name_clean, r, tooltip=tooltip)
599
 
600
+ related_methods = {}
601
+ related_by_group = {}
602
+
603
+ for cdr_method, resource in cost_sliders.items():
604
+ for name, coeff in resource.items():
605
+ if r == strip_unit_suffix(name):
606
+ related_methods[cdr_method] = coeff
607
+
608
+ for m in related_methods:
609
+ method_group = next(
610
+ (g for g, methods in method_groups.items() if m in methods),
611
+ "Other"
612
+ )
613
+ related_by_group.setdefault(method_group, []).append(m)
614
+
615
+ related_method_names = list(related_methods.keys())
616
+ secondary_for_r = [m for m in related_method_names if (m, r) in secondary_resources]
617
+ primary_for_r = [m for m in related_method_names if (m, r) not in secondary_resources]
618
+
619
+ if r not in st.session_state.enabled_methods:
620
+ st.session_state.enabled_methods[r] = {}
621
+ for m in related_method_names:
622
+ st.session_state.enabled_methods[r].setdefault(m, True)
623
+ popover_key_prefix = f"pop_{r}"
624
+
625
+ with st.popover(
626
+ f":material/tune: Related CDR Methods",
627
+ use_container_width=True,
628
+ ):
629
+ for method_group, methods in related_by_group.items():
630
+ primary_in_group = [m for m in methods if m in primary_for_r]
631
+ if not primary_in_group:
632
+ continue
633
+ st.markdown(f"*{method_group}*")
634
+ for m in primary_in_group:
635
+ widget_key = f"{popover_key_prefix}_{m}"
636
+ if widget_key not in st.session_state:
637
+ st.session_state[widget_key] = st.session_state.enabled_methods[r][m]
638
+
639
+ checked = st.checkbox(m, key=widget_key)
640
+ st.session_state.enabled_methods[r][m] = checked
641
+
642
+ if secondary_for_r:
643
+ st.caption(
644
+ "Secondary methods that can be added in the **Resource Coefficient** tab:\n\n"
645
+ + "\n".join(f"- {m}" for m in secondary_for_r)
646
+ )
647
+
648
+ for m in secondary_for_r:
649
+ st.session_state.enabled_methods[r][m] = True
650
+
651
+ # Custom batches for this group
652
+ group_unit = resource_unit_map.get(group, "")
653
+ group_batches = [
654
+ (idx, b) for idx, b in enumerate(st.session_state.get("custom_resources", []))
655
+ if resource_to_group.get(b["group"]) == group
656
+ ]
657
+ for global_idx, batch in group_batches:
658
+ _render_custom_batch_inline(global_idx, batch, cost_sliders, group_unit)
659
+
660
+ _, col_button = st.columns([3,1])
661
+ with col_button:
662
+ if st.button(
663
+ "Add resource", key=f"simple-btn-resource-{group}",
664
+ type="tertiary", icon=":material/add:", use_container_width=True
665
+ ):
666
+ dialog_add_custom_resource(cost_sliders, preset_resources=rlist)
667
+
668
+ # ─── Vérification globale : si une méthode n'a plus aucune ressource
669
+ # primaire active, on la désactive directement (constraints[m]["active"] = False)
670
+ # plutôt que de bloquer le décochage. C'est un état valide : l'utilisateur a
671
+ # choisi de retirer cette méthode de l'optimisation pour cette ressource.
672
+ method_active_resource_count = {}
673
+ for r, methods in st.session_state.enabled_methods.items():
674
+ for m, ok in methods.items():
675
+ if (m, r) in secondary_resources:
676
+ continue
677
+ method_active_resource_count.setdefault(m, 0)
678
+ if ok:
679
+ method_active_resource_count[m] += 1
680
+
681
+ methods_with_no_active_resource = [
682
+ m for m in method_to_primary_resources
683
+ if method_active_resource_count.get(m, 0) == 0
684
+ ]
685
+
686
+ newly_disabled = []
687
+ for m in methods_with_no_active_resource:
688
+ if st.session_state.constraints.get(m, {}).get("active", True):
689
+ st.session_state.constraints.setdefault(m, {"active": True, "cap_type": "percent", "cap_value": 100})
690
+ st.session_state.constraints[m]["active"] = False
691
+ newly_disabled.append(m)
692
+ toggle_key = f"toggle_{m}"
693
+ if toggle_key in st.session_state:
694
+ st.session_state[toggle_key] = False
695
+
696
+ if newly_disabled:
697
+ st.info(
698
+ "The following methods have no active resource left and were "
699
+ "automatically deactivated (see Method constraints tab): "
700
+ + ", ".join(newly_disabled),
701
+ icon=":material/info:"
702
+ )
703
+
704
+
705
+ # Controls: New resource / Load / Download
706
+ panel_custom_resources_controls(cost_sliders)
707
 
708
  # Summary table
709
  st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True)
 
724
  for resource, value in df_caps["Available Amount"].items()
725
  ]
726
 
 
 
 
 
 
 
727
  custom_rows = []
728
  for batch in st.session_state.get("custom_resources", []):
729
  group = resource_to_group.get(batch["group"])
tabs/tab2_constraints.py CHANGED
@@ -10,7 +10,7 @@ from utils.ui_helpers import format_table_summary, show_bar
10
  # ─── Helpers ─────────────────────────────────────────────────────────────────
11
 
12
  def default_constraint():
13
- return {"active": True, "cap_type": "percent", "cap_value": 100}
14
 
15
 
16
  def render_method(m, cols, i):
@@ -35,7 +35,7 @@ def render_method(m, cols, i):
35
  st.session_state.setdefault(cap_type_key, constraint["cap_type"])
36
 
37
  if constraint["cap_type"] == "percent":
38
- st.session_state.setdefault(slider_key, float(constraint["cap_value"]))
39
  else:
40
  st.session_state.setdefault(manual_key, float(constraint["cap_value"]))
41
 
 
10
  # ─── Helpers ─────────────────────────────────────────────────────────────────
11
 
12
  def default_constraint():
13
+ return {"active": True, "cap_type": "percent", "cap_value": int(100)}
14
 
15
 
16
  def render_method(m, cols, i):
 
35
  st.session_state.setdefault(cap_type_key, constraint["cap_type"])
36
 
37
  if constraint["cap_type"] == "percent":
38
+ st.session_state.setdefault(slider_key, int(constraint["cap_value"]))
39
  else:
40
  st.session_state.setdefault(manual_key, float(constraint["cap_value"]))
41
 
tabs/tab3_coefficients.py CHANGED
@@ -6,12 +6,10 @@ from config.config import (method_groups, method_order, resource_groups,
6
  from utils.ui_helpers import format_efficiency_summary, show_bar
7
  from utils.data_utils import strip_unit_suffix
8
 
9
-
10
  def _normalize_costs(costs_dict: dict) -> dict:
11
  """Strip unit suffixes from resource keys for legacy scenario compatibility."""
12
  return {strip_unit_suffix(r): v for r, v in costs_dict.items()}
13
 
14
-
15
  def _get_slider_params(conf: dict) -> tuple:
16
  """Return (step, fmt) based on the min value of a slider config."""
17
  min_val = conf["min"]
@@ -24,6 +22,30 @@ def _get_slider_params(conf: dict) -> tuple:
24
  else:
25
  return 1.0, "%.0f"
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  def render_tab(cost_sliders):
29
  st.markdown('<h1 class="crra-heading">Resource efficiency coefficients</h1>', unsafe_allow_html=True)
@@ -52,9 +74,19 @@ def render_tab(cost_sliders):
52
 
53
  with st.container(key="warning_resources"):
54
  st.warning(
55
- "Blue cells in the table highlight coefficients modified from their **default median values**",
56
  icon=":material/warning:"
57
  )
 
 
 
 
 
 
 
 
 
 
58
  # Normalize cost_sliders once: strip unit suffixes from resource keys
59
  # cost_sliders_norm[method][resource_name] = {min, median, max}
60
  cost_sliders_norm = {
@@ -69,6 +101,56 @@ def render_tab(cost_sliders):
69
  for r in resources
70
  }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  # ── Method selector ──────────────────────────────────────────────────────
73
  method_choices = []
74
  method_lookup = {}
@@ -79,7 +161,7 @@ def render_tab(cost_sliders):
79
  method_lookup[label] = method
80
 
81
  selected_label = st.selectbox("Select a method:", method_choices, key="crra-select")
82
-
83
  if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
84
  for m, sliders in cost_sliders_norm.items():
85
  for r, conf in sliders.items():
@@ -87,6 +169,11 @@ def render_tab(cost_sliders):
87
  st.session_state.costs[m][r] = 0.0 if is_secondary else conf["median"]
88
  for prefix in ["slider", "manual", "toggle"]:
89
  st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
 
 
 
 
 
90
  st.success("All methods reset to default values.")
91
  st.rerun()
92
 
@@ -96,16 +183,27 @@ def render_tab(cost_sliders):
96
  with st.expander(f"Manage resources for **{method}**", expanded=True):
97
  method_sliders = cost_sliders_norm.get(method, {})
98
  method_costs_norm = _normalize_costs(st.session_state.costs.get(method, {}))
 
99
 
100
  for group_name, rlist in resource_groups.items():
101
  used = [r for r in rlist if r in method_sliders]
102
- if not used:
 
 
 
 
 
103
  continue
104
  st.markdown(f"""
105
  <p class="crra-group-name">{group_name}</p>""",
106
  unsafe_allow_html=True,
107
  )
108
  for r in used:
 
 
 
 
 
109
  conf = method_sliders[r]
110
  display_label = resource_display.get(r, r)
111
  step, fmt = _get_slider_params(conf)
@@ -154,6 +252,64 @@ def render_tab(cost_sliders):
154
 
155
  st.session_state.costs[method][r] = manual_val if manual_val != slider_val else slider_val
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  # ── Reset buttons ────────────────────────────────────────────────────
158
  if st.button("Reset selected method", key="btn-reset-method", icon=":material/reset_settings:"):
159
  for r, conf in cost_sliders_norm.get(method, {}).items():
@@ -161,6 +317,10 @@ def render_tab(cost_sliders):
161
  st.session_state.costs[method][r] = 0.0 if is_secondary else conf["median"]
162
  for prefix in ["slider", "manual", "toggle"]:
163
  st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
 
 
 
 
164
  st.success(f"{method} reset to default values.")
165
  st.rerun()
166
 
@@ -169,17 +329,21 @@ def render_tab(cost_sliders):
169
  <p class="crra-sub-heading">Summary of resource usage coefficients</p>""",
170
  unsafe_allow_html=True,
171
  )
172
- df_default = pd.DataFrame(index=method_order, columns=resource_order)
173
- df_current = pd.DataFrame(index=method_order, columns=resource_order)
 
 
174
 
175
  for m in method_order:
176
  current_costs = _normalize_costs(st.session_state.costs.get(m, {}))
177
- sliders = cost_sliders_norm.get(m, {})
 
178
 
179
- for r in resource_order:
180
- is_secondary = (m, r) in secondary_resources
181
- current_val = current_costs.get(r, 0.0)
182
- default_val = sliders.get(r, {}).get("median", 0.0)
 
183
 
184
  if is_secondary and current_val == 0.0:
185
  default_val = 0.0
@@ -187,11 +351,22 @@ def render_tab(cost_sliders):
187
  df_default.at[m, r] = default_val
188
  df_current.at[m, r] = current_val
189
 
190
- df_default = df_default.astype(float)
191
- df_current = df_current.astype(float)
192
 
193
- df_default = df_default.rename(columns=resource_display)
194
- df_current = df_current.rename(columns=resource_display)
 
 
 
195
 
196
  styled = format_efficiency_summary(df_current, df_default)
197
- st.table(styled)
 
 
 
 
 
 
 
 
 
6
  from utils.ui_helpers import format_efficiency_summary, show_bar
7
  from utils.data_utils import strip_unit_suffix
8
 
 
9
  def _normalize_costs(costs_dict: dict) -> dict:
10
  """Strip unit suffixes from resource keys for legacy scenario compatibility."""
11
  return {strip_unit_suffix(r): v for r, v in costs_dict.items()}
12
 
 
13
  def _get_slider_params(conf: dict) -> tuple:
14
  """Return (step, fmt) based on the min value of a slider config."""
15
  min_val = conf["min"]
 
22
  else:
23
  return 1.0, "%.0f"
24
 
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
+ """Zero out st.session_state.costs[method][resource] for every (method, resource)
31
+ pair the user disabled in tab1 — even if that pair was never opened in this tab.
32
+ Without this, a disabled pair silently keeps its last/default coefficient and
33
+ gets exported/optimised as if it were still active."""
34
+ for r in resource_order_:
35
+ disallowed = {m for m, ok in enabled_methods.get(r, {}).items() if not ok}
36
+ if not disallowed:
37
+ continue
38
+ for m in disallowed:
39
+ if m in st.session_state.costs and r in st.session_state.costs[m]:
40
+ st.session_state.costs[m][r] = 0.0
41
+
42
+ for batch in custom_resources or []:
43
+ r = batch["name"]
44
+ disallowed = {m for m, ok in enabled_methods.get(r, {}).items() if not ok}
45
+ for m in disallowed:
46
+ if m in st.session_state.costs and r in st.session_state.costs[m]:
47
+ st.session_state.costs[m][r] = 0.0
48
+
49
 
50
  def render_tab(cost_sliders):
51
  st.markdown('<h1 class="crra-heading">Resource efficiency coefficients</h1>', unsafe_allow_html=True)
 
74
 
75
  with st.container(key="warning_resources"):
76
  st.warning(
77
+ "Red cells in the table highlight coefficients modified from their **default median values**",
78
  icon=":material/warning:"
79
  )
80
+
81
+ # Read the live, per-resource manual allocation state (set in tab1) and the
82
+ # custom resource batches — both are read fresh on every render, not cached
83
+ # at import time, so toggling a method on/off in tab1 is reflected here
84
+ # immediately without needing a session restart.
85
+ enabled_methods = st.session_state.get("enabled_methods", {})
86
+ custom_resources = st.session_state.get("custom_resources", [])
87
+
88
+ _enforce_disabled_coefficients(method_order, resource_order, custom_resources, enabled_methods)
89
+
90
  # Normalize cost_sliders once: strip unit suffixes from resource keys
91
  # cost_sliders_norm[method][resource_name] = {min, median, max}
92
  cost_sliders_norm = {
 
101
  for r in resources
102
  }
103
 
104
+ # Custom (user-added) resources: name -> {method: {min, median, max}}.
105
+ # Primary links come from the dialog (batch["methods"]).
106
+ # Secondary links are auto-injected: when the reference resource (batch["group"])
107
+ # is secondary for a method, inherit that method's coefficients automatically.
108
+ custom_sliders_by_method = {}
109
+ custom_resource_names = set()
110
+ for batch in custom_resources:
111
+ r_name = batch["name"]
112
+ custom_resource_names.add(r_name)
113
+ # Explicit primary links chosen in the dialog
114
+ for m, coefs in batch["methods"].items():
115
+ custom_sliders_by_method.setdefault(m, {})[r_name] = coefs
116
+ # Auto-inject secondary method links from reference resource cost_sliders
117
+ ref = batch["group"]
118
+ for m, resources in cost_sliders_norm.items():
119
+ if (m, ref) in secondary_resources and r_name not in custom_sliders_by_method.get(m, {}):
120
+ ref_coefs = resources.get(ref)
121
+ if ref_coefs:
122
+ custom_sliders_by_method.setdefault(m, {})[r_name] = ref_coefs
123
+
124
+ # custom resource name → reference resource name (batch["group"])
125
+ custom_resource_to_sub_group = {
126
+ batch["name"]: batch["group"]
127
+ for batch in custom_resources
128
+ }
129
+ resource_to_group = {
130
+ r: group
131
+ for group, resources in resource_groups.items()
132
+ for r in resources
133
+ }
134
+ custom_resource_to_group = {
135
+ r: resource_to_group[s_g]
136
+ for r, s_g in custom_resource_to_sub_group.items()
137
+ if s_g in resource_to_group
138
+ }
139
+
140
+ def is_custom_secondary(method_name, custom_r):
141
+ """True if the reference resource for custom_r is secondary for method_name."""
142
+ sub_group = custom_resource_to_sub_group.get(custom_r)
143
+ return sub_group is not None and (method_name, sub_group) in secondary_resources
144
+
145
+ # Pre-initialize costs for custom resources so the summary table shows the
146
+ # correct value immediately, without needing to open the expander first.
147
+ # Secondary custom resources start at 0 (like standard secondary resources).
148
+ for m, resources in custom_sliders_by_method.items():
149
+ st.session_state.costs.setdefault(m, {})
150
+ for r, conf in resources.items():
151
+ if r not in st.session_state.costs[m]:
152
+ st.session_state.costs[m][r] = 0.0 if is_custom_secondary(m, r) else conf["median"]
153
+
154
  # ── Method selector ──────────────────────────────────────────────────────
155
  method_choices = []
156
  method_lookup = {}
 
161
  method_lookup[label] = method
162
 
163
  selected_label = st.selectbox("Select a method:", method_choices, key="crra-select")
164
+
165
  if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
166
  for m, sliders in cost_sliders_norm.items():
167
  for r, conf in sliders.items():
 
169
  st.session_state.costs[m][r] = 0.0 if is_secondary else conf["median"]
170
  for prefix in ["slider", "manual", "toggle"]:
171
  st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
172
+ for m, sliders in custom_sliders_by_method.items():
173
+ for r, conf in sliders.items():
174
+ st.session_state.costs.setdefault(m, {})[r] = 0.0 if is_custom_secondary(m, r) else conf["median"]
175
+ for prefix in ["slider", "manual", "toggle"]:
176
+ st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
177
  st.success("All methods reset to default values.")
178
  st.rerun()
179
 
 
183
  with st.expander(f"Manage resources for **{method}**", expanded=True):
184
  method_sliders = cost_sliders_norm.get(method, {})
185
  method_costs_norm = _normalize_costs(st.session_state.costs.get(method, {}))
186
+ custom_for_method = custom_sliders_by_method.get(method, {})
187
 
188
  for group_name, rlist in resource_groups.items():
189
  used = [r for r in rlist if r in method_sliders]
190
+ custom_in_group = [
191
+ r for r in custom_for_method
192
+ if custom_resource_to_group.get(r) == group_name
193
+ ] if custom_for_method else []
194
+
195
+ if not used and not custom_in_group:
196
  continue
197
  st.markdown(f"""
198
  <p class="crra-group-name">{group_name}</p>""",
199
  unsafe_allow_html=True,
200
  )
201
  for r in used:
202
+ enabled = is_method_enabled(method, r, enabled_methods)
203
+ if not enabled:
204
+ st.info(f"INFO: Method disabled for **{r}** (see Resource Inputs tab)")
205
+ st.session_state.costs[method][r] = 0.0
206
+ continue
207
  conf = method_sliders[r]
208
  display_label = resource_display.get(r, r)
209
  step, fmt = _get_slider_params(conf)
 
252
 
253
  st.session_state.costs[method][r] = manual_val if manual_val != slider_val else slider_val
254
 
255
+ # ── New (custom) resources linked to this method ────────────────
256
+
257
+ for r in custom_in_group:
258
+ conf = custom_for_method[r]
259
+ enabled = is_method_enabled(method, r, enabled_methods)
260
+ if not enabled:
261
+ st.info("INFO: Method disabled for this resource (see Resource Inputs tab)")
262
+ st.session_state.costs.setdefault(method, {})[r] = 0.0
263
+ continue
264
+
265
+ _is_cust_secondary = is_custom_secondary(method, r)
266
+ display_label = r
267
+ step, fmt = _get_slider_params(conf)
268
+ toggle_key = f"toggle_{method}_{r}".replace(" ", "_").lower()
269
+ slider_key = f"slider_{method}_{r}".replace(" ", "_").lower()
270
+ manual_key = f"manual_{method}_{r}".replace(" ", "_").lower()
271
+
272
+ current_val = st.session_state.costs.get(method, {}).get(r, 0.0 if _is_cust_secondary else conf["median"])
273
+
274
+ if _is_cust_secondary:
275
+ if toggle_key not in st.session_state:
276
+ st.session_state[toggle_key] = current_val != 0.0
277
+ use_resource = st.checkbox(f"Use {display_label}", key=toggle_key)
278
+ if not use_resource:
279
+ st.session_state.costs.setdefault(method, {})[r] = 0.0
280
+ st.markdown("**DISABLED**")
281
+ continue
282
+ elif current_val == 0.0:
283
+ st.session_state.costs.setdefault(method, {})[r] = conf["median"]
284
+ current_val = conf["median"]
285
+
286
+ if slider_key not in st.session_state:
287
+ st.session_state[slider_key] = current_val
288
+
289
+ col1, col2 = st.columns([3, 1])
290
+ with col1:
291
+ slider_val = st.slider(
292
+ label=display_label,
293
+ min_value=conf["min"],
294
+ max_value=conf["max"],
295
+ step=step,
296
+ format=fmt,
297
+ key=slider_key,
298
+ )
299
+ with col2:
300
+ manual_val = st.number_input(
301
+ label="label",
302
+ label_visibility="collapsed",
303
+ value=slider_val,
304
+ key=manual_key,
305
+ help="Manual override (optional, any value)",
306
+ format=fmt,
307
+ )
308
+
309
+ st.session_state.costs.setdefault(method, {})
310
+ st.session_state.costs[method][r] = manual_val if manual_val != slider_val else slider_val
311
+
312
+
313
  # ── Reset buttons ────────────────────────────────────────────────────
314
  if st.button("Reset selected method", key="btn-reset-method", icon=":material/reset_settings:"):
315
  for r, conf in cost_sliders_norm.get(method, {}).items():
 
317
  st.session_state.costs[method][r] = 0.0 if is_secondary else conf["median"]
318
  for prefix in ["slider", "manual", "toggle"]:
319
  st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
320
+ for r, conf in custom_sliders_by_method.get(method, {}).items():
321
+ st.session_state.costs.setdefault(method, {})[r] = conf["median"]
322
+ for prefix in ["slider", "manual"]:
323
+ st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
324
  st.success(f"{method} reset to default values.")
325
  st.rerun()
326
 
 
329
  <p class="crra-sub-heading">Summary of resource usage coefficients</p>""",
330
  unsafe_allow_html=True,
331
  )
332
+ all_resource_columns = resource_order + sorted(custom_resource_names)
333
+
334
+ df_default = pd.DataFrame(index=method_order, columns=all_resource_columns)
335
+ df_current = pd.DataFrame(index=method_order, columns=all_resource_columns)
336
 
337
  for m in method_order:
338
  current_costs = _normalize_costs(st.session_state.costs.get(m, {}))
339
+ sliders = dict(cost_sliders_norm.get(m, {}))
340
+ sliders.update(custom_sliders_by_method.get(m, {}))
341
 
342
+ for r in all_resource_columns:
343
+ is_secondary = (m, r) in secondary_resources or is_custom_secondary(m, r)
344
+ disabled = not is_method_enabled(m, r, enabled_methods)
345
+ current_val = 0.0 if disabled else current_costs.get(r, 0.0)
346
+ default_val = 0.0 if disabled else sliders.get(r, {}).get("median", 0.0)
347
 
348
  if is_secondary and current_val == 0.0:
349
  default_val = 0.0
 
351
  df_default.at[m, r] = default_val
352
  df_current.at[m, r] = current_val
353
 
354
+ df_default = df_default.astype(float).fillna(0.0)
355
+ df_current = df_current.astype(float).fillna(0.0)
356
 
357
+ resource_display_full = dict(resource_display)
358
+ resource_display_full.update({r: r for r in custom_resource_names})
359
+
360
+ df_default = df_default.rename(columns=resource_display_full)
361
+ df_current = df_current.rename(columns=resource_display_full)
362
 
363
  styled = format_efficiency_summary(df_current, df_default)
364
+ st.dataframe(
365
+ data=styled,
366
+ use_container_width=True,
367
+ column_config={
368
+ "_index": st.column_config.TextColumn(
369
+ "Method",
370
+ # width="large"
371
+ )
372
+ })
tabs/tab4_optimization.py CHANGED
@@ -6,6 +6,7 @@ import unicodedata
6
  import zipfile
7
  import openpyxl
8
 
 
9
  import pandas as pd
10
  import streamlit as st
11
  import plotly.express as px
@@ -13,7 +14,26 @@ import plotly.express as px
13
  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
14
  from optimization.optimization import run_optimization
15
  from utils.ui_helpers import apply_chart_style, format_table_summary, register_chart_data, show_bar
16
- from utils.data_utils import is_valid_scenario, load_inputs_from_excel, strip_unit_suffix
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  # ─── Input panels ────────────────────────────────────────────────────────────
@@ -59,11 +79,12 @@ def panel_load_from_excel():
59
  if st.button("↩️ Load inputs into all tabs", use_container_width=True):
60
  st.session_state.setdefault("input_file_name_suggestion", default_name)
61
  try:
62
- resource_caps, constraints, costs, custom_resources = load_inputs_from_excel(uploaded)
63
  st.session_state.resource_caps = resource_caps
64
  st.session_state.constraints = constraints
65
  st.session_state.costs = costs
66
  st.session_state.custom_resources = custom_resources
 
67
  clear_widget_keys()
68
  st.session_state.pop("latest_result", None)
69
  st.session_state.pop("scenario_loaded_file", None)
@@ -77,7 +98,17 @@ def panel_load_from_excel():
77
  st.error(f"Error loading file: {e}")
78
 
79
  def parse_zip_scenario(uploaded) -> dict | None:
80
- """Reconstruct a scenario dict from a ZIP containing the 4 CSVs."""
 
 
 
 
 
 
 
 
 
 
81
 
82
  try:
83
  with zipfile.ZipFile(io.BytesIO(uploaded.read())) as zf:
@@ -153,16 +184,69 @@ def parse_zip_scenario(uploaded) -> dict | None:
153
  "name": bname,
154
  "group": str(df_b["group"].iloc[0]),
155
  "amount": float(df_b["amount"].iloc[0]),
 
156
  "methods": methods,
157
  })
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  return {
160
  "summary": {"total_co2_removed": total, "total_cost": 0},
161
  "allocations": allocations,
 
 
 
162
  "resource_caps": resource_caps,
163
  "method_constraints": constraints,
164
  "method_costs": costs,
165
  "custom_resources": custom_resources,
 
166
  }
167
 
168
  except Exception:
@@ -198,7 +282,7 @@ def panel_load_from_json_or_csv():
198
  if data is None:
199
  st.error(
200
  "Invalid ZIP. Expected: resource_caps.csv, method_constraints.csv, "
201
- "method_costs.csv, allocations.csv"
202
  )
203
  return
204
 
@@ -215,6 +299,7 @@ def panel_load_from_json_or_csv():
215
  st.session_state.costs = data["method_costs"].copy()
216
  # Restore custom resource batches if present in the scenario
217
  st.session_state.custom_resources = data.get("custom_resources", [])
 
218
  clear_widget_keys()
219
  st.session_state["scenario_loaded_file"] = uploaded_json.name
220
  st.session_state["scenario_name"] = default_name
@@ -235,6 +320,7 @@ def clear_widget_keys():
235
 
236
  def run_and_store_result():
237
  custom_resources = st.session_state.get("custom_resources", [])
 
238
 
239
  # Check that at least some resource is non-zero (standard OR custom)
240
  std_total = sum(st.session_state.resource_caps.values())
@@ -243,12 +329,22 @@ def run_and_store_result():
243
  if std_total <= 0 and custom_total <= 0:
244
  st.warning("⚠️ All resources are set to 0. Please set resource availability or load a scenario / input data.")
245
  return
 
 
 
 
 
 
 
 
 
246
 
247
  success, result = run_optimization(
248
  resource_caps=st.session_state.resource_caps,
249
  method_constraints=st.session_state.constraints,
250
  method_costs=st.session_state.costs,
251
  custom_resources=custom_resources,
 
252
  )
253
 
254
  if not success:
@@ -256,6 +352,12 @@ def run_and_store_result():
256
  if "message" in result:
257
  st.error(result["message"])
258
  return
 
 
 
 
 
 
259
 
260
  allocations = result["method_usage"]
261
  total_removed = result["total_removed"]
@@ -271,7 +373,6 @@ def run_and_store_result():
271
  st.session_state.latest_result = {
272
  "summary": {"total_co2_removed": total_removed, "total_cost": total_cost},
273
  "allocations": allocations,
274
- "method_potential": result["method_potential"],
275
  "resource_usage": result["resource_usage"],
276
  "resource_used_total": result["resource_used_total"],
277
  "resource_remaining": result["resource_remaining"],
@@ -279,6 +380,7 @@ def run_and_store_result():
279
  "method_constraints": st.session_state.constraints,
280
  "method_costs": st.session_state.costs,
281
  "custom_resources": custom_resources,
 
282
  }
283
  if custom_resources:
284
  st.info(f"ℹ️ {len(custom_resources)} new resource(s) included in optimisation.")
@@ -324,9 +426,16 @@ def build_resource_usage_df(resource_usage):
324
  for r, methods in resource_usage.items():
325
  unit = resource_unit_map.get(resource_to_group.get(r))
326
  if unit is None:
327
- for batch in st.session_state.custom_resources:
328
- group = resource_to_group.get(batch["group"])
329
- unit = resource_unit_map.get(group, '?')
 
 
 
 
 
 
 
330
 
331
  for m, qty in methods.items():
332
  rows.append({
@@ -337,7 +446,7 @@ def build_resource_usage_df(resource_usage):
337
 
338
  return pd.DataFrame(rows)
339
 
340
- def build_results_df(allocations, method_potential=None, method_constraints=None):
341
  df = pd.DataFrame.from_dict(allocations, orient="index", columns=[f"{CO2_UNIT} removed"])
342
 
343
  df[f"{CO2_UNIT} removed"] = pd.to_numeric(df[f"{CO2_UNIT} removed"], errors="coerce")
@@ -345,15 +454,6 @@ def build_results_df(allocations, method_potential=None, method_constraints=None
345
  if total > 0:
346
  df["Total share (%)"] = (df[f"{CO2_UNIT} removed"] / total * 100).round(2)
347
 
348
- if method_potential:
349
- df["Potential (" + CO2_UNIT + ")"] = pd.Series(method_potential)
350
- # df["Allocated / Potential (%)"] = (
351
- # df[f"{CO2_UNIT} removed"] / df["Potential (" + CO2_UNIT + ")"] * 100
352
- # ).round(1)
353
- df["Potential (" + CO2_UNIT + ")"] = df[
354
- "Potential (" + CO2_UNIT + ")"
355
- ].map(lambda x: f"{x:,.2f}" if pd.notna(x) else x)
356
-
357
  if method_constraints:
358
  caps = {}
359
  for m in df.index:
@@ -378,26 +478,21 @@ def show_donut(df_result):
378
  st.plotly_chart(fig, use_container_width=True)
379
  register_chart_data("Method allocation (donut)", df_plot)
380
 
381
- def show_heatmap(df_result, resource_caps, method_costs, custom_resources=None):
382
  # Normalize standard method_costs keys (strip legacy unit suffixes)
383
  normalized_costs = {
384
  m: {strip_unit_suffix(r): v for r, v in resources.items()}
385
  for m, resources in method_costs.items()
386
  }
387
 
388
- # Inject custom resource coefficients (median)
389
- for batch in (custom_resources or []):
390
- for method, coefs in batch["methods"].items():
391
- normalized_costs.setdefault(method, {})[batch["name"]] = coefs["median"]
392
-
393
  # Build complete caps — resource_caps may not contain custom batches
394
- # if latest_result was generated before the enriched_caps fix
395
  full_caps = dict(resource_caps)
396
  for batch in (custom_resources or []):
397
  full_caps.setdefault(batch["name"], float(batch["amount"]))
398
 
399
  standard_available = [r for r in resource_order if full_caps.get(r, 0) > 0]
400
- extra_available = [b["name"] for b in (custom_resources or [])]
 
401
  available = standard_available + extra_available
402
 
403
  if not available:
@@ -406,11 +501,20 @@ def show_heatmap(df_result, resource_caps, method_costs, custom_resources=None):
406
 
407
  matrix, y_labels = [], []
408
  for method in df_result.index:
409
- row = [
410
- (normalized_costs.get(method, {}).get(r, 0.0) * df_result.at[method, f"{CO2_UNIT} removed"]
411
- / full_caps[r] * 100) if full_caps.get(r, 0) > 0 else 0
412
- for r in available
413
- ]
 
 
 
 
 
 
 
 
 
414
  matrix.append(row)
415
  y_labels.append(method)
416
 
@@ -430,37 +534,26 @@ def show_heatmap(df_result, resource_caps, method_costs, custom_resources=None):
430
 
431
  register_chart_data("Resource usage heatmap (% of cap)", df_export)
432
 
433
- def show_absolute_usage(allocations, method_costs, custom_resources=None):
434
  # Normalize method_costs keys: strip legacy unit suffixes
435
  normalized_costs = {
436
  m: {strip_unit_suffix(r): v for r, v in resources.items()}
437
  for m, resources in method_costs.items()
438
  }
439
-
440
  # Build resource_units from standard groups
441
  _resource_units = defaultdict(list)
 
442
  for cat, resources in resource_groups.items():
443
- unit_key = f"{resource_unit_map[cat]}/MtCO2"
444
- _resource_units[unit_key].extend(resources)
445
 
446
- resource_to_group = {
447
- r: group
448
- for group, resources in resource_groups.items()
449
- for r in resources
450
- }
451
-
452
- # Inject custom resource batches into the unit map
453
  for batch in (custom_resources or []):
454
- group = resource_to_group.get(batch["group"])
455
- unit_key = f"{resource_unit_map.get(group, '?')}/MtCO2"
456
- if batch["name"] not in _resource_units[unit_key]:
457
- _resource_units[unit_key].append(batch["name"])
458
- # Make sure the median coefficient is in normalized_costs
459
- for method, coefs in batch["methods"].items():
460
- if method in normalized_costs:
461
- normalized_costs[method].setdefault(batch["name"], coefs["median"])
462
- else:
463
- normalized_costs[method] = {batch["name"]: coefs["median"]}
464
 
465
  local_resource_units = dict(_resource_units)
466
 
@@ -468,9 +561,21 @@ def show_absolute_usage(allocations, method_costs, custom_resources=None):
468
  for m, co2 in allocations.items():
469
  for unit, resources in local_resource_units.items():
470
  for r in resources:
471
- cost = normalized_costs.get(m, {}).get(r)
472
- if cost is not None and cost * co2 > 0:
473
- rows.append({"Method": m, "Resource": r, "Used": cost * co2, "Unit": unit})
 
 
 
 
 
 
 
 
 
 
 
 
474
  if not rows:
475
  return
476
 
@@ -503,7 +608,7 @@ def show_resource_balance(latest):
503
  rows = []
504
  for r in resource_caps:
505
  group = resource_to_group.get(r)
506
- unit = f"{resource_unit_map.get(group, '?')}"
507
  cap = resource_caps[r]
508
  if cap <= 0:
509
  continue
@@ -575,19 +680,19 @@ def export_scenario():
575
  name = st.session_state.get("scenario_name", "").strip()
576
  if not name or name not in st.session_state.scenarios:
577
  return
 
 
 
 
 
 
578
 
579
  scenario = st.session_state.scenarios[name]
580
- # st.markdown(f"#### Scenario: {name}")
581
- # df_saved = build_results_df(scenario["allocations"], method_constraints=scenario.get("method_constraints"))
582
-
583
- # st.markdown(format_table_summary(df_saved,"cdr-table-res"), unsafe_allow_html=True)
584
-
585
  safe = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
586
  col_json, col_csv = st.columns(2)
587
-
588
  with col_json:
589
  st.download_button(
590
- "Export JSON", data=json.dumps(scenario, indent=2),
591
  file_name=f"{safe}.json", mime="application/json",
592
  use_container_width=True,
593
  icon=":material/download:"
@@ -608,12 +713,14 @@ def export_scenario():
608
  "cap_type": "cap_type (percent or absolute)"
609
  }).to_csv()
610
  )
 
611
  zf.writestr("method_costs.csv",
612
  pd.DataFrame.from_dict(scenario["method_costs"], orient="index").to_csv())
 
613
  zf.writestr("allocations.csv",
614
  pd.DataFrame.from_dict(scenario["allocations"], orient="index",
615
  columns=[f"{CO2_UNIT} removed"]).to_csv())
616
- # Custom resource batches — one row per (batch × method)
617
  custom_batches = scenario.get("custom_resources", [])
618
  if custom_batches:
619
  custom_rows = []
@@ -623,6 +730,7 @@ def export_scenario():
623
  "name": batch["name"],
624
  "group": batch["group"],
625
  "amount": batch["amount"],
 
626
  "method": method,
627
  "min": coefs["min"],
628
  "median": coefs["median"],
@@ -630,6 +738,33 @@ def export_scenario():
630
  })
631
  zf.writestr("custom_resources.csv", pd.DataFrame(custom_rows).to_csv(index=False))
632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  with col_csv:
634
  st.download_button(
635
  "Export CSV (zip)", data=buf.getvalue(),
@@ -653,6 +788,12 @@ def export_inputs_only():
653
  st.session_state["input_file_name"] = suggested
654
  st.session_state["_input_file_name_source"] = suggested
655
 
 
 
 
 
 
 
656
  col_text, col_btn = st.columns([3, 1])
657
  with col_text:
658
  st.text_input(
@@ -667,6 +808,11 @@ def export_inputs_only():
667
  if prepare:
668
  safe = st.session_state.get("input_file_name", "").strip() or suggested or "crra_inputs"
669
 
 
 
 
 
 
670
  wb = openpyxl.Workbook()
671
  ws1 = wb.active
672
  ws1.title = "resource_caps"
@@ -679,27 +825,40 @@ def export_inputs_only():
679
  for m, c in st.session_state.constraints.items():
680
  ws2.append([m, c.get("active", True), c.get("cap_type", "percent"), c.get("cap_value", 100)])
681
 
 
 
 
682
  ws3 = wb.create_sheet("coefficients")
683
  ws3.append(["method", "resource", "value", "secondary"])
684
  for m, resources in st.session_state.costs.items():
685
  for r, v in resources.items():
686
- ws3.append([m, r, v, (m, r) in secondary_resources])
 
687
 
688
  ws4 = wb.create_sheet("custom_resources")
689
  ws4.append(["name", "group", "amount", "unit", "method", "min", "median", "max"])
690
  for batch in st.session_state.get("custom_resources", []):
 
691
  for method, c in batch["methods"].items():
692
  ws4.append([
693
  batch["name"],
694
  batch["group"],
695
  batch["amount"],
696
- resource_unit_map.get(batch["group"]),
697
  method,
698
  c["min"],
699
  c["median"],
700
  c["max"],
701
  ])
702
 
 
 
 
 
 
 
 
 
703
  buf = io.BytesIO()
704
  wb.save(buf)
705
 
@@ -716,8 +875,6 @@ def export_inputs_only():
716
  icon=":material/download:"
717
  )
718
 
719
- import zipfile
720
-
721
  def export_all_charts():
722
  st.markdown("### Export all chart data")
723
  st.markdown(
@@ -841,6 +998,8 @@ def render_tab():
841
  ("scenario_uploader_key", 0)
842
  ]:
843
  st.session_state.setdefault(key, default)
 
 
844
 
845
  # ── Two-column input area ──────────────────────────────────────────────
846
  st.markdown("""
@@ -884,7 +1043,6 @@ def render_tab():
884
  show_metrics(latest)
885
  df_result = build_results_df(
886
  allocations,
887
- method_potential=latest.get("method_potential"),
888
  method_constraints=latest.get("method_constraints")
889
  )
890
 
@@ -895,22 +1053,9 @@ def render_tab():
895
  )
896
 
897
  st.subheader("Optimization Summary")
898
- # st.markdown(
899
- # format_table_summary(df_display, "cdr-table-res"),
900
- # unsafe_allow_html=True
901
- # )
902
 
903
  st.dataframe(
904
  df_display,
905
- column_config={
906
- "Potential (MtCO₂)": st.column_config.NumberColumn(
907
- "Potential (MtCO₂)",
908
- help="Maximum CO₂ this method could remove if limited only by "
909
- "resources and its own cap, without competition from other "
910
- "methods. For % caps, this value ignores the cap.",
911
- format="%.0f",
912
- ),
913
- },
914
  use_container_width=True,
915
  )
916
 
@@ -918,10 +1063,14 @@ def render_tab():
918
  show_donut(df_result)
919
 
920
  st.markdown("### Resource usage heatmap (% of cap)")
921
- show_heatmap(df_result, resource_caps, method_costs, custom_resources=latest.get("custom_resources", []))
 
 
922
 
923
  st.markdown("### Absolute resource usage by unit")
924
- show_absolute_usage(allocations, method_costs, custom_resources=latest.get("custom_resources", []))
 
 
925
 
926
  show_resource_balance(latest)
927
  df_usage = build_resource_usage_df(latest["resource_usage"])
 
6
  import zipfile
7
  import openpyxl
8
 
9
+ import numpy as np
10
  import pandas as pd
11
  import streamlit as st
12
  import plotly.express as px
 
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
+
20
+ def _to_json_safe(obj):
21
+ """Recursively convert numpy/pandas types to plain Python so json.dumps works."""
22
+ if isinstance(obj, dict):
23
+ return {k: _to_json_safe(v) for k, v in obj.items()}
24
+ if isinstance(obj, list):
25
+ return [_to_json_safe(v) for v in obj]
26
+ if isinstance(obj, pd.Series):
27
+ return obj.iloc[0] if len(obj) == 1 else obj.tolist()
28
+ if isinstance(obj, pd.DataFrame):
29
+ return obj.to_dict(orient="records")
30
+ if isinstance(obj, np.integer):
31
+ return int(obj)
32
+ if isinstance(obj, np.floating):
33
+ return float(obj)
34
+ if isinstance(obj, np.ndarray):
35
+ return obj.tolist()
36
+ return obj
37
 
38
 
39
  # ─── Input panels ────────────────────────────────────────────────────────────
 
79
  if st.button("↩️ Load inputs into all tabs", use_container_width=True):
80
  st.session_state.setdefault("input_file_name_suggestion", default_name)
81
  try:
82
+ resource_caps, constraints, costs, custom_resources, enabled_methods = load_inputs_from_excel(uploaded)
83
  st.session_state.resource_caps = resource_caps
84
  st.session_state.constraints = constraints
85
  st.session_state.costs = costs
86
  st.session_state.custom_resources = custom_resources
87
+ st.session_state.enabled_methods = enabled_methods
88
  clear_widget_keys()
89
  st.session_state.pop("latest_result", None)
90
  st.session_state.pop("scenario_loaded_file", None)
 
98
  st.error(f"Error loading file: {e}")
99
 
100
  def parse_zip_scenario(uploaded) -> dict | None:
101
+ """Reconstruct a scenario dict from a ZIP containing the exported CSVs.
102
+
103
+ Required: resource_caps.csv, method_constraints.csv, allocations.csv, method_costs.csv.
104
+ Optional: custom_resources.csv, enabled_methods.csv.
105
+
106
+ The detailed outputs (resource_usage, resource_used_total, resource_remaining)
107
+ are NOT trusted from allocations.csv alone — they are
108
+ recomputed by re-running the optimizer on the reloaded inputs. This guarantees
109
+ the reloaded scenario is always internally consistent (and lets results pages
110
+ render without missing keys), instead of relying on whatever was exported.
111
+ """
112
 
113
  try:
114
  with zipfile.ZipFile(io.BytesIO(uploaded.read())) as zf:
 
184
  "name": bname,
185
  "group": str(df_b["group"].iloc[0]),
186
  "amount": float(df_b["amount"].iloc[0]),
187
+ "unit": str(df_b["unit"].iloc[0]) if "unit" in df_b.columns else "?",
188
  "methods": methods,
189
  })
190
 
191
+ # Load optional per-resource manual method allocation
192
+ enabled_methods = {}
193
+ if "enabled_methods.csv" in names:
194
+ df_enabled = pd.read_csv(io.BytesIO(zf.read("enabled_methods.csv")))
195
+ required_enabled = {"resource", "method", "enabled"}
196
+ if required_enabled.issubset(df_enabled.columns):
197
+ for _, row in df_enabled.iterrows():
198
+ enabled_methods.setdefault(str(row["resource"]), {})[str(row["method"])] = bool(row["enabled"])
199
+
200
+ safe_constraints = neutralize_zero_coefficient_methods(
201
+ st.session_state.costs, st.session_state.constraints
202
+ )
203
+ neutralized = [
204
+ m for m in safe_constraints
205
+ if safe_constraints[m].get("active") is False
206
+ and st.session_state.constraints.get(m, {}).get("active") is True
207
+ ]
208
+
209
+ # Recompute detailed outputs from the reloaded inputs so the scenario
210
+ # is internally consistent (rather than trusting allocations.csv alone).
211
+ success, result = run_optimization(
212
+ resource_caps=resource_caps,
213
+ method_constraints=constraints,
214
+ method_costs=costs,
215
+ custom_resources=custom_resources,
216
+ enabled_methods=enabled_methods,
217
+ )
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:
226
+ allocations = result["method_usage"]
227
+ total = result["total_removed"]
228
+ resource_usage = result["resource_usage"]
229
+ resource_used_total = result["resource_used_total"]
230
+ resource_remaining = result["resource_remaining"]
231
+ else:
232
+ # Fall back to the raw exported allocations if re-optimisation
233
+ # fails (e.g. incompatible inputs) — better than returning None.
234
+ allocations = df[col].to_dict()
235
+ total = sum(allocations.values())
236
+ resource_usage = {}
237
+ resource_used_total, resource_remaining = {}, {}
238
+
239
  return {
240
  "summary": {"total_co2_removed": total, "total_cost": 0},
241
  "allocations": allocations,
242
+ "resource_usage": resource_usage,
243
+ "resource_used_total": resource_used_total,
244
+ "resource_remaining": resource_remaining,
245
  "resource_caps": resource_caps,
246
  "method_constraints": constraints,
247
  "method_costs": costs,
248
  "custom_resources": custom_resources,
249
+ "enabled_methods": enabled_methods,
250
  }
251
 
252
  except Exception:
 
282
  if data is None:
283
  st.error(
284
  "Invalid ZIP. Expected: resource_caps.csv, method_constraints.csv, "
285
+ "method_costs.csv, allocations.csv, "
286
  )
287
  return
288
 
 
299
  st.session_state.costs = data["method_costs"].copy()
300
  # Restore custom resource batches if present in the scenario
301
  st.session_state.custom_resources = data.get("custom_resources", [])
302
+ st.session_state.enabled_methods = data.get("enabled_methods", {})
303
  clear_widget_keys()
304
  st.session_state["scenario_loaded_file"] = uploaded_json.name
305
  st.session_state["scenario_name"] = default_name
 
320
 
321
  def run_and_store_result():
322
  custom_resources = st.session_state.get("custom_resources", [])
323
+ enabled_methods = st.session_state.get("enabled_methods", {}) # for custom standard resources
324
 
325
  # Check that at least some resource is non-zero (standard OR custom)
326
  std_total = sum(st.session_state.resource_caps.values())
 
329
  if std_total <= 0 and custom_total <= 0:
330
  st.warning("⚠️ All resources are set to 0. Please set resource availability or load a scenario / input data.")
331
  return
332
+
333
+ safe_constraints = neutralize_zero_coefficient_methods(
334
+ st.session_state.costs, st.session_state.constraints
335
+ )
336
+ neutralized = [
337
+ m for m in safe_constraints
338
+ if safe_constraints[m].get("active") is False
339
+ and st.session_state.constraints.get(m, {}).get("active") is True
340
+ ]
341
 
342
  success, result = run_optimization(
343
  resource_caps=st.session_state.resource_caps,
344
  method_constraints=st.session_state.constraints,
345
  method_costs=st.session_state.costs,
346
  custom_resources=custom_resources,
347
+ enabled_methods=enabled_methods,
348
  )
349
 
350
  if not success:
 
352
  if "message" in result:
353
  st.error(result["message"])
354
  return
355
+
356
+ if neutralized:
357
+ st.info(
358
+ f"ℹ️ {len(neutralized)} méthode(s) désactivée(s) automatiquement (aucune ressource active) : "
359
+ + ", ".join(neutralized)
360
+ )
361
 
362
  allocations = result["method_usage"]
363
  total_removed = result["total_removed"]
 
373
  st.session_state.latest_result = {
374
  "summary": {"total_co2_removed": total_removed, "total_cost": total_cost},
375
  "allocations": allocations,
 
376
  "resource_usage": result["resource_usage"],
377
  "resource_used_total": result["resource_used_total"],
378
  "resource_remaining": result["resource_remaining"],
 
380
  "method_constraints": st.session_state.constraints,
381
  "method_costs": st.session_state.costs,
382
  "custom_resources": custom_resources,
383
+ "enabled_methods": enabled_methods,
384
  }
385
  if custom_resources:
386
  st.info(f"ℹ️ {len(custom_resources)} new resource(s) included in optimisation.")
 
426
  for r, methods in resource_usage.items():
427
  unit = resource_unit_map.get(resource_to_group.get(r))
428
  if unit is None:
429
+ # Custom resource — find the matching batch by name, not any batch
430
+ batch = next(
431
+ (b for b in st.session_state.get("custom_resources", []) if b["name"] == r),
432
+ None,
433
+ )
434
+ if batch:
435
+ group_name = resource_to_group.get(batch["group"])
436
+ unit = resource_unit_map.get(group_name, "?")
437
+ else:
438
+ unit = "?"
439
 
440
  for m, qty in methods.items():
441
  rows.append({
 
446
 
447
  return pd.DataFrame(rows)
448
 
449
+ def build_results_df(allocations, method_constraints=None):
450
  df = pd.DataFrame.from_dict(allocations, orient="index", columns=[f"{CO2_UNIT} removed"])
451
 
452
  df[f"{CO2_UNIT} removed"] = pd.to_numeric(df[f"{CO2_UNIT} removed"], errors="coerce")
 
454
  if total > 0:
455
  df["Total share (%)"] = (df[f"{CO2_UNIT} removed"] / total * 100).round(2)
456
 
 
 
 
 
 
 
 
 
 
457
  if method_constraints:
458
  caps = {}
459
  for m in df.index:
 
478
  st.plotly_chart(fig, use_container_width=True)
479
  register_chart_data("Method allocation (donut)", df_plot)
480
 
481
+ def show_heatmap(df_result, resource_caps, method_costs, custom_resources=None, resource_usage=None):
482
  # Normalize standard method_costs keys (strip legacy unit suffixes)
483
  normalized_costs = {
484
  m: {strip_unit_suffix(r): v for r, v in resources.items()}
485
  for m, resources in method_costs.items()
486
  }
487
 
 
 
 
 
 
488
  # Build complete caps — resource_caps may not contain custom batches
 
489
  full_caps = dict(resource_caps)
490
  for batch in (custom_resources or []):
491
  full_caps.setdefault(batch["name"], float(batch["amount"]))
492
 
493
  standard_available = [r for r in resource_order if full_caps.get(r, 0) > 0]
494
+ custom_names = {b["name"] for b in (custom_resources or [])}
495
+ extra_available = [b["name"] for b in (custom_resources or []) if float(b["amount"]) > 0]
496
  available = standard_available + extra_available
497
 
498
  if not available:
 
501
 
502
  matrix, y_labels = [], []
503
  for method in df_result.index:
504
+ row = []
505
+ for r in available:
506
+ cap = full_caps.get(r, 0)
507
+ if cap <= 0:
508
+ row.append(0)
509
+ continue
510
+ if resource_usage is not None:
511
+ # Use actual LP usage (correctly handles z-split for custom batches
512
+ # and the reduced standard usage when a batch absorbs part of the load)
513
+ used = resource_usage.get(r, {}).get(method, 0.0)
514
+ else:
515
+ # Fallback for legacy scenarios without resource_usage
516
+ used = normalized_costs.get(method, {}).get(r, 0.0) * df_result.at[method, f"{CO2_UNIT} removed"]
517
+ row.append(used / cap * 100)
518
  matrix.append(row)
519
  y_labels.append(method)
520
 
 
534
 
535
  register_chart_data("Resource usage heatmap (% of cap)", df_export)
536
 
537
+ def show_absolute_usage(allocations, method_costs, custom_resources=None, resource_usage=None):
538
  # Normalize method_costs keys: strip legacy unit suffixes
539
  normalized_costs = {
540
  m: {strip_unit_suffix(r): v for r, v in resources.items()}
541
  for m, resources in method_costs.items()
542
  }
 
543
  # Build resource_units from standard groups
544
  _resource_units = defaultdict(list)
545
+ resource_to_group = {r: group for group, resources in resource_groups.items() for r in resources}
546
  for cat, resources in resource_groups.items():
547
+ _resource_units[resource_unit_map[cat]].extend(resources)
 
548
 
549
+ # Inject custom resource batches using the correct unit from their reference group
550
+ custom_batch_names = set()
 
 
 
 
 
551
  for batch in (custom_resources or []):
552
+ custom_batch_names.add(batch["name"])
553
+ group_name = resource_to_group.get(batch["group"])
554
+ correct_unit = resource_unit_map.get(group_name, "?")
555
+ if batch["name"] not in _resource_units[correct_unit]:
556
+ _resource_units[correct_unit].append(batch["name"])
 
 
 
 
 
557
 
558
  local_resource_units = dict(_resource_units)
559
 
 
561
  for m, co2 in allocations.items():
562
  for unit, resources in local_resource_units.items():
563
  for r in resources:
564
+ if r in custom_batch_names:
565
+ # Use actual LP usage — coefficient × total_y_m overcounts when
566
+ # standard and custom pools are split across z variables
567
+ used_qty = (resource_usage or {}).get(r, {}).get(m, 0.0)
568
+ if used_qty > 1e-9:
569
+ rows.append({"Method": m, "Resource": r, "Used": used_qty, "Unit": unit})
570
+ else:
571
+ if resource_usage is not None:
572
+ used_qty = resource_usage.get(r, {}).get(m, 0.0)
573
+ if used_qty > 1e-9:
574
+ rows.append({"Method": m, "Resource": r, "Used": used_qty, "Unit": unit})
575
+ else:
576
+ cost = normalized_costs.get(m, {}).get(r)
577
+ if cost is not None and cost * co2 > 0:
578
+ rows.append({"Method": m, "Resource": r, "Used": cost * co2, "Unit": unit})
579
  if not rows:
580
  return
581
 
 
608
  rows = []
609
  for r in resource_caps:
610
  group = resource_to_group.get(r)
611
+ unit = resource_unit_map.get(group)
612
  cap = resource_caps[r]
613
  if cap <= 0:
614
  continue
 
680
  name = st.session_state.get("scenario_name", "").strip()
681
  if not name or name not in st.session_state.scenarios:
682
  return
683
+
684
+ resource_to_group = {
685
+ r: group
686
+ for group, resources in resource_groups.items()
687
+ for r in resources
688
+ }
689
 
690
  scenario = st.session_state.scenarios[name]
 
 
 
 
 
691
  safe = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
692
  col_json, col_csv = st.columns(2)
 
693
  with col_json:
694
  st.download_button(
695
+ "Export JSON", data=json.dumps(_to_json_safe(scenario), indent=2),
696
  file_name=f"{safe}.json", mime="application/json",
697
  use_container_width=True,
698
  icon=":material/download:"
 
713
  "cap_type": "cap_type (percent or absolute)"
714
  }).to_csv()
715
  )
716
+
717
  zf.writestr("method_costs.csv",
718
  pd.DataFrame.from_dict(scenario["method_costs"], orient="index").to_csv())
719
+
720
  zf.writestr("allocations.csv",
721
  pd.DataFrame.from_dict(scenario["allocations"], orient="index",
722
  columns=[f"{CO2_UNIT} removed"]).to_csv())
723
+
724
  custom_batches = scenario.get("custom_resources", [])
725
  if custom_batches:
726
  custom_rows = []
 
730
  "name": batch["name"],
731
  "group": batch["group"],
732
  "amount": batch["amount"],
733
+ "unit": resource_unit_map.get(resource_to_group.get(batch["group"])),
734
  "method": method,
735
  "min": coefs["min"],
736
  "median": coefs["median"],
 
738
  })
739
  zf.writestr("custom_resources.csv", pd.DataFrame(custom_rows).to_csv(index=False))
740
 
741
+ enabled_methods = scenario.get("enabled_methods", {})
742
+ if enabled_methods:
743
+ enabled_rows = [
744
+ {"resource": r, "method": m, "enabled": ok}
745
+ for r, methods in enabled_methods.items()
746
+ for m, ok in methods.items()
747
+ ]
748
+ zf.writestr("enabled_methods.csv", pd.DataFrame(enabled_rows).to_csv(index=False))
749
+
750
+ resource_used_total = scenario.get("resource_used_total", {})
751
+ resource_remaining = scenario.get("resource_remaining", {})
752
+ if resource_used_total or resource_remaining:
753
+ df_balance = pd.DataFrame({
754
+ "Used": pd.Series(resource_used_total),
755
+ "Remaining": pd.Series(resource_remaining),
756
+ })
757
+ zf.writestr("resource_balance.csv", df_balance.to_csv())
758
+
759
+ resource_usage = scenario.get("resource_usage", {})
760
+ if resource_usage:
761
+ usage_rows = [
762
+ {"resource": r, "method": m, "used": qty}
763
+ for r, methods in resource_usage.items()
764
+ for m, qty in methods.items()
765
+ ]
766
+ zf.writestr("resource_usage_detail.csv", pd.DataFrame(usage_rows).to_csv(index=False))
767
+
768
  with col_csv:
769
  st.download_button(
770
  "Export CSV (zip)", data=buf.getvalue(),
 
788
  st.session_state["input_file_name"] = suggested
789
  st.session_state["_input_file_name_source"] = suggested
790
 
791
+ resource_to_group = {
792
+ r: group
793
+ for group, resources in resource_groups.items()
794
+ for r in resources
795
+ }
796
+
797
  col_text, col_btn = st.columns([3, 1])
798
  with col_text:
799
  st.text_input(
 
808
  if prepare:
809
  safe = st.session_state.get("input_file_name", "").strip() or suggested or "crra_inputs"
810
 
811
+ enabled_methods = st.session_state.get("enabled_methods", {})
812
+
813
+ def _is_allowed(method, resource):
814
+ return enabled_methods.get(resource, {}).get(method, True)
815
+
816
  wb = openpyxl.Workbook()
817
  ws1 = wb.active
818
  ws1.title = "resource_caps"
 
825
  for m, c in st.session_state.constraints.items():
826
  ws2.append([m, c.get("active", True), c.get("cap_type", "percent"), c.get("cap_value", 100)])
827
 
828
+ # Coefficients — a disabled (method, resource) pair is always exported
829
+ # as 0, regardless of whether the user ever opened it in tab3. This
830
+ # keeps the export consistent with what the optimizer actually uses.
831
  ws3 = wb.create_sheet("coefficients")
832
  ws3.append(["method", "resource", "value", "secondary"])
833
  for m, resources in st.session_state.costs.items():
834
  for r, v in resources.items():
835
+ value = 0.0 if not _is_allowed(m, r) else v
836
+ ws3.append([m, r, value, (m, r) in secondary_resources])
837
 
838
  ws4 = wb.create_sheet("custom_resources")
839
  ws4.append(["name", "group", "amount", "unit", "method", "min", "median", "max"])
840
  for batch in st.session_state.get("custom_resources", []):
841
+ unit = resource_unit_map.get(resource_to_group.get(batch["group"]))
842
  for method, c in batch["methods"].items():
843
  ws4.append([
844
  batch["name"],
845
  batch["group"],
846
  batch["amount"],
847
+ unit,
848
  method,
849
  c["min"],
850
  c["median"],
851
  c["max"],
852
  ])
853
 
854
+ # Per-resource manual method allocation (objective 2) — which CDR
855
+ # methods are allowed to use each resource batch.
856
+ ws5 = wb.create_sheet("enabled_methods")
857
+ ws5.append(["resource", "method", "enabled"])
858
+ for r, methods in enabled_methods.items():
859
+ for m, ok in methods.items():
860
+ ws5.append([r, m, bool(ok)])
861
+
862
  buf = io.BytesIO()
863
  wb.save(buf)
864
 
 
875
  icon=":material/download:"
876
  )
877
 
 
 
878
  def export_all_charts():
879
  st.markdown("### Export all chart data")
880
  st.markdown(
 
998
  ("scenario_uploader_key", 0)
999
  ]:
1000
  st.session_state.setdefault(key, default)
1001
+ if "enabled_methods" not in st.session_state:
1002
+ st.session_state.enabled_methods = {}
1003
 
1004
  # ── Two-column input area ──────────────────────────────────────────────
1005
  st.markdown("""
 
1043
  show_metrics(latest)
1044
  df_result = build_results_df(
1045
  allocations,
 
1046
  method_constraints=latest.get("method_constraints")
1047
  )
1048
 
 
1053
  )
1054
 
1055
  st.subheader("Optimization Summary")
 
 
 
 
1056
 
1057
  st.dataframe(
1058
  df_display,
 
 
 
 
 
 
 
 
 
1059
  use_container_width=True,
1060
  )
1061
 
 
1063
  show_donut(df_result)
1064
 
1065
  st.markdown("### Resource usage heatmap (% of cap)")
1066
+ show_heatmap(df_result, resource_caps, method_costs,
1067
+ custom_resources=latest.get("custom_resources", []),
1068
+ resource_usage=latest.get("resource_usage"))
1069
 
1070
  st.markdown("### Absolute resource usage by unit")
1071
+ show_absolute_usage(allocations, method_costs,
1072
+ custom_resources=latest.get("custom_resources", []),
1073
+ resource_usage=latest.get("resource_usage"))
1074
 
1075
  show_resource_balance(latest)
1076
  df_usage = build_resource_usage_df(latest["resource_usage"])
utils/data_utils.py CHANGED
@@ -32,7 +32,7 @@ def render_resource_input(name_clean, r, tooltip=None):
32
  st.session_state.resource_caps[r] = cap
33
 
34
  def is_valid_scenario(scenario):
35
- return (
36
  isinstance(scenario, dict)
37
  and "allocations" in scenario
38
  and "resource_caps" in scenario
@@ -40,6 +40,18 @@ def is_valid_scenario(scenario):
40
  and "method_constraints" in scenario
41
  and "summary" in scenario
42
  )
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  def load_inputs_from_excel(file):
45
  xl = pd.read_excel(file, sheet_name=None)
@@ -52,20 +64,11 @@ def load_inputs_from_excel(file):
52
  constraints = {}
53
  if "constraints" in xl:
54
  for _, row in xl["constraints"].iterrows():
55
- # Compatibility with older files that used `max_share`
56
- # if "cap_type (percent or absolute)" in xl["constraints"].columns:
57
  constraints[row["method"]] = {
58
  "active": bool(row["active"]),
59
  "cap_type": str(row["cap_type (percent or absolute)"]),
60
  "cap_value": float(row["cap_value"])
61
  }
62
- # else:
63
- # constraints[row["method"]] = {
64
- # "active": bool(row["active"]),
65
- # "cap_type": "percent",
66
- # "cap_value": float(row["max_share"])
67
- # }
68
-
69
  costs = {}
70
  if "coefficients" in xl:
71
  for _, row in xl["coefficients"].iterrows():
@@ -73,33 +76,55 @@ def load_inputs_from_excel(file):
73
  costs.setdefault(m, {})[r] = v
74
 
75
  custom_resources = []
76
- print("--------------------------------")
77
  if "custom_resources" in xl:
78
  df_custom = xl["custom_resources"]
79
- if not df_custom.empty:
80
- for name in df_custom["name"].unique():
81
- df_b = df_custom[df_custom["name"] == name]
82
- batch = {
83
- "name": name,
84
- "group": df_b["group"].iloc[0],
85
- "amount": float(df_b["amount"].iloc[0]),
86
- "unit": resource_unit_map.get(
87
- df_b["group"].iloc[0],
88
- ""
89
- ),
90
- "methods": {}
91
- }
92
-
93
- for _, row in df_b.iterrows():
94
- batch["methods"][row["method"]] = {
95
  "min": float(row["min"]),
96
  "median": float(row["median"]),
97
  "max": float(row["max"]),
98
  }
99
- custom_resources.append(batch)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- return resource_caps, constraints, costs, custom_resources
102
 
103
  def strip_unit_suffix(resource_name: str) -> str:
104
  """Remove legacy unit suffix from resource name, e.g. 'Unspecified land (ha/tCO2)' -> 'Unspecified land'"""
105
- return re.sub(r"\s*\(.*?\)\s*$", "", resource_name).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  st.session_state.resource_caps[r] = cap
33
 
34
  def is_valid_scenario(scenario):
35
+ base_ok = (
36
  isinstance(scenario, dict)
37
  and "allocations" in scenario
38
  and "resource_caps" in scenario
 
40
  and "method_constraints" in scenario
41
  and "summary" in scenario
42
  )
43
+ if not base_ok:
44
+ return False
45
+ return True
46
+
47
+ def is_complete_scenario(scenario):
48
+ """A scenario that also carries the detailed optimisation outputs and the
49
+ per-resource manual allocation state, so reloading it reproduces the
50
+ exact same optimisation (not just the same inputs)."""
51
+ required_extra = {
52
+ "resource_usage", "resource_used_total", "resource_remaining","enabled_methods",
53
+ }
54
+ return is_valid_scenario(scenario) and required_extra.issubset(scenario.keys())
55
 
56
  def load_inputs_from_excel(file):
57
  xl = pd.read_excel(file, sheet_name=None)
 
64
  constraints = {}
65
  if "constraints" in xl:
66
  for _, row in xl["constraints"].iterrows():
 
 
67
  constraints[row["method"]] = {
68
  "active": bool(row["active"]),
69
  "cap_type": str(row["cap_type (percent or absolute)"]),
70
  "cap_value": float(row["cap_value"])
71
  }
 
 
 
 
 
 
 
72
  costs = {}
73
  if "coefficients" in xl:
74
  for _, row in xl["coefficients"].iterrows():
 
76
  costs.setdefault(m, {})[r] = v
77
 
78
  custom_resources = []
 
79
  if "custom_resources" in xl:
80
  df_custom = xl["custom_resources"]
81
+ required_custom = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
82
+ if required_custom.issubset(df_custom.columns):
83
+ for bname in df_custom["name"].dropna().unique():
84
+ df_b = df_custom[df_custom["name"] == bname]
85
+ methods = {
86
+ row["method"]: {
 
 
 
 
 
 
 
 
 
 
87
  "min": float(row["min"]),
88
  "median": float(row["median"]),
89
  "max": float(row["max"]),
90
  }
91
+ for _, row in df_b.iterrows()
92
+ }
93
+ custom_resources.append({
94
+ "name": str(bname),
95
+ "group": str(df_b["group"].iloc[0]),
96
+ "amount": float(df_b["amount"].iloc[0]),
97
+ "unit": str(df_b["unit"].iloc[0]),
98
+ "methods": methods,
99
+ })
100
+
101
+ # Per-resource manual method allocation (which CDR methods are allowed to
102
+ # use a given resource). Optional sheet for backward compatibility with
103
+ # older templates that predate this feature.
104
+ enabled_methods = {}
105
+ if "enabled_methods" in xl:
106
+ df_enabled = xl["enabled_methods"]
107
+ required_enabled = {"resource", "method", "enabled"}
108
+ if required_enabled.issubset(df_enabled.columns):
109
+ for _, row in df_enabled.iterrows():
110
+ r, m = row["resource"], row["method"]
111
+ enabled_methods.setdefault(r, {})[m] = bool(row["enabled"])
112
 
113
+ return resource_caps, constraints, costs, custom_resources, enabled_methods
114
 
115
  def strip_unit_suffix(resource_name: str) -> str:
116
  """Remove legacy unit suffix from resource name, e.g. 'Unspecified land (ha/tCO2)' -> 'Unspecified land'"""
117
+ return re.sub(r"\s*\(.*?\)\s*$", "", resource_name).strip()
118
+
119
+ def neutralize_zero_coefficient_methods(method_costs: dict, method_constraints: dict) -> dict:
120
+ safe_constraints = {m: c.copy() for m, c in method_constraints.items()}
121
+
122
+ for method, constraint in safe_constraints.items():
123
+ if not constraint.get("active", True):
124
+ continue
125
+ coeffs = method_costs.get(method, {})
126
+ all_zero = all(v == 0 for v in coeffs.values()) if coeffs else True
127
+ if all_zero:
128
+ constraint["active"] = False
129
+
130
+ return safe_constraints
utils/ui_helpers.py CHANGED
@@ -82,9 +82,6 @@ def format_table_summary(df: pd.DataFrame, class_name: str):
82
 
83
  def format_efficiency_summary(df: pd.DataFrame, df_default: pd.DataFrame):
84
  df_numeric = df.copy()
85
-
86
- df_numeric.index.name = "Method"
87
-
88
  df_display = df_numeric.map(smart_display_format)
89
 
90
  # Highlight modified values
@@ -96,24 +93,17 @@ def format_efficiency_summary(df: pd.DataFrame, df_default: pd.DataFrame):
96
  current = df_numeric.at[m, r]
97
  default = df_default.at[m, r]
98
  if abs(current - default) > 1e-6:
99
- styled.at[m, r] = 'background-color: #cce5ff'
100
  except:
101
  continue
102
  return styled
103
 
104
  # Table style
105
- num_columns = df.shape[1]
106
- styles = [
107
- {"selector": "thead th", "props": [("background-color", background_color), ("color", "white"), ("font-weight", "bold")]},
108
- {"selector": "th.row_heading", "props": [("text-align", "left"), ("font-weight", "bold"), ("width", "240px")]}
109
- ]
110
- for i in range(num_columns):
111
- styles.append({
112
- "selector": f"td.col{i}",
113
- "props": [("text-align", "center"), ("width", "160px")]
114
- })
115
-
116
- return df_display.style.apply(highlight_changes, axis=None).set_table_styles(styles)
117
 
118
  # -----------------------------
119
  # SLIDER GROUP HELPER
 
82
 
83
  def format_efficiency_summary(df: pd.DataFrame, df_default: pd.DataFrame):
84
  df_numeric = df.copy()
 
 
 
85
  df_display = df_numeric.map(smart_display_format)
86
 
87
  # Highlight modified values
 
93
  current = df_numeric.at[m, r]
94
  default = df_default.at[m, r]
95
  if abs(current - default) > 1e-6:
96
+ styled.at[m, r] = 'background-color: #ff5f4d'
97
  except:
98
  continue
99
  return styled
100
 
101
  # Table style
102
+ return (
103
+ df_display
104
+ .style
105
+ .apply(highlight_changes, axis=None)
106
+ )
 
 
 
 
 
 
 
107
 
108
  # -----------------------------
109
  # SLIDER GROUP HELPER