Spaces:
Sleeping
Sleeping
[FEAT] Objective 2: Custom resource batches with supplementary LP semantics
Browse files- assets/styles.css +20 -0
- config/config.py +4 -1
- data/generate_cost_matrix.py +0 -2
- optimization/optimization.py +277 -138
- tabs/tab1_inputs.py +279 -194
- tabs/tab2_constraints.py +2 -2
- tabs/tab3_coefficients.py +192 -17
- tabs/tab4_optimization.py +233 -84
- utils/data_utils.py +55 -30
- utils/ui_helpers.py +6 -16
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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
if
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
#
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_",
|
| 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 =
|
| 71 |
batch_group = st.selectbox(
|
| 72 |
"Resource group *",
|
| 73 |
-
options=
|
| 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 |
-
#
|
| 100 |
-
|
| 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 |
-
|
| 105 |
"min": coeff["min"],
|
| 106 |
"median": coeff["median"],
|
| 107 |
"max": coeff["max"],
|
| 108 |
}
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
| 114 |
method_group = next(
|
| 115 |
(g for g, methods in method_groups.items() if m in methods),
|
| 116 |
"Other"
|
| 117 |
)
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
for method_group, methods in
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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(
|
| 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 |
-
|
| 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":
|
| 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
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 411 |
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 608 |
for i, r in enumerate(rlist):
|
|
|
|
| 609 |
tooltip = resource_tooltips.get(r, None)
|
| 610 |
name_clean = r.split(" (")[0]
|
| 611 |
-
|
|
|
|
|
|
|
| 612 |
render_resource_input(name_clean, r, tooltip=tooltip)
|
| 613 |
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 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
|
| 180 |
-
is_secondary = (m, r) in secondary_resources
|
| 181 |
-
|
| 182 |
-
|
|
|
|
| 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 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
styled = format_efficiency_summary(df_current, df_default)
|
| 197 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 411 |
-
|
| 412 |
-
|
| 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 |
-
|
| 444 |
-
_resource_units[unit_key].extend(resources)
|
| 445 |
|
| 446 |
-
|
| 447 |
-
|
| 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 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 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 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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,
|
|
|
|
|
|
|
| 922 |
|
| 923 |
st.markdown("### Absolute resource usage by unit")
|
| 924 |
-
show_absolute_usage(allocations, method_costs,
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: #
|
| 100 |
except:
|
| 101 |
continue
|
| 102 |
return styled
|
| 103 |
|
| 104 |
# Table style
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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
|