Spaces:
Sleeping
Sleeping
[FIX] Resolve percent caps against each method's standalone potential
Browse filesPreviously, "percent" caps were applied as a fraction of the combined
portfolio output — a self-referential formula where one method's cap
depended on every other active method's output. An out-of-range cap
(e.g. from a corrupted scenario file) could silently zero the entire
portfolio.
Now, each method's percent cap is resolved against its own isolated
maximum: the output it would achieve if it were the only active method,
unconstrained. Adds compute_standalone_potential() in optimization.py
and integrates the conversion in run_optimization() before the LP is
solved. Clamps cap_value to [0, 100] before computing.
Wording updated across tab0, tab2, tab4, tab5, and data_utils:
"% of total potential" / "% of potential" → "% of its potential".
- optimization/optimization.py +59 -0
- tabs/tab0_welcome.py +1 -1
- tabs/tab2_constraints.py +1 -1
- tabs/tab4_optimization.py +1 -1
- tabs/tab5_sensitivity.py +2 -2
- utils/data_utils.py +1 -1
optimization/optimization.py
CHANGED
|
@@ -452,6 +452,41 @@ def run_perturbation(constraint_labels, A_ub, b_ub, objective_coefficients, boun
|
|
| 452 |
return resource_actual_gain
|
| 453 |
|
| 454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
def run_optimization(
|
| 456 |
resource_caps,
|
| 457 |
method_constraints,
|
|
@@ -503,6 +538,30 @@ def run_optimization(
|
|
| 503 |
m: {**c, "cap_value": 0.0},
|
| 504 |
}
|
| 505 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
custom_batch_specs, group_to_custom_batches, custom_batch_names = build_custom_batch_structures(
|
| 507 |
method_costs, resource_caps, custom_resources
|
| 508 |
)
|
|
|
|
| 452 |
return resource_actual_gain
|
| 453 |
|
| 454 |
|
| 455 |
+
def compute_standalone_potential(method, resource_caps, method_costs, custom_resources, enabled_methods):
|
| 456 |
+
"""Compute a method's own maximum achievable output, in isolation.
|
| 457 |
+
|
| 458 |
+
"Isolation" means solving as if this were the only active method — no
|
| 459 |
+
competition for shared resources from any other method, and no cap of
|
| 460 |
+
its own applied. This is what "% of its total potential" (the method's
|
| 461 |
+
OWN ceiling) should be a percentage of, as opposed to a self-referential
|
| 462 |
+
"% of the combined output of every active method", which depends on
|
| 463 |
+
what every other method happens to be doing.
|
| 464 |
+
|
| 465 |
+
Args:
|
| 466 |
+
method (str): the method to isolate.
|
| 467 |
+
resource_caps (dict): {resource: available amount}.
|
| 468 |
+
method_costs (dict): {method: {resource: coefficient}} — already
|
| 469 |
+
reflecting any enabled_methods zeroing.
|
| 470 |
+
custom_resources (list | None): custom batch dicts (safe to pass the
|
| 471 |
+
full list — batches tied to other methods simply produce no
|
| 472 |
+
usage pairs once every other method is excluded).
|
| 473 |
+
enabled_methods (dict | None): {resource: {method: bool}}.
|
| 474 |
+
|
| 475 |
+
Returns:
|
| 476 |
+
float: the method's standalone potential in MtCO₂ (0.0 if it can't
|
| 477 |
+
produce anything on its own).
|
| 478 |
+
"""
|
| 479 |
+
_UNCAPPED = 1e15
|
| 480 |
+
success, result = run_optimization(
|
| 481 |
+
resource_caps=resource_caps,
|
| 482 |
+
method_constraints={method: {"active": True, "cap_type": "absolute", "cap_value": _UNCAPPED}},
|
| 483 |
+
method_costs={method: dict(method_costs.get(method, {}))},
|
| 484 |
+
custom_resources=custom_resources,
|
| 485 |
+
enabled_methods=enabled_methods,
|
| 486 |
+
)
|
| 487 |
+
return result["total_removed"] if success else 0.0
|
| 488 |
+
|
| 489 |
+
|
| 490 |
def run_optimization(
|
| 491 |
resource_caps,
|
| 492 |
method_constraints,
|
|
|
|
| 538 |
m: {**c, "cap_value": 0.0},
|
| 539 |
}
|
| 540 |
|
| 541 |
+
# Convert "percent" caps into an equivalent absolute cap computed from the
|
| 542 |
+
# method's OWN standalone potential (its max output if it were the only
|
| 543 |
+
# active method, unconstrained by any cap) — matches the documented
|
| 544 |
+
# meaning of "% of its total potential", instead of a self-referential
|
| 545 |
+
# "% of the combined output of every active method". The old
|
| 546 |
+
# self-referential form made one method's cap depend on what every other
|
| 547 |
+
# active method happened to produce, and — since it's a ratio against a
|
| 548 |
+
# value that includes the capped method itself — an out-of-range percent
|
| 549 |
+
# (e.g. a negative value from a corrupted import) could force the ENTIRE
|
| 550 |
+
# portfolio to zero, not just the misconfigured method. Clamp cap_value to
|
| 551 |
+
# [0, 100] first (the UI slider already restricts to this range, but a
|
| 552 |
+
# loaded scenario file might not).
|
| 553 |
+
resolved_constraints = {}
|
| 554 |
+
for m, c in method_constraints.items():
|
| 555 |
+
if c.get("cap_type") == "percent" and c.get("active", True):
|
| 556 |
+
cap_value = max(0.0, min(100.0, c.get("cap_value", 100)))
|
| 557 |
+
potential = compute_standalone_potential(
|
| 558 |
+
m, resource_caps, method_costs, custom_resources, enabled_methods,
|
| 559 |
+
)
|
| 560 |
+
resolved_constraints[m] = {**c, "cap_type": "absolute", "cap_value": potential * cap_value / 100.0}
|
| 561 |
+
else:
|
| 562 |
+
resolved_constraints[m] = c
|
| 563 |
+
method_constraints = resolved_constraints
|
| 564 |
+
|
| 565 |
custom_batch_specs, group_to_custom_batches, custom_batch_names = build_custom_batch_structures(
|
| 566 |
method_costs, resource_caps, custom_resources
|
| 567 |
)
|
tabs/tab0_welcome.py
CHANGED
|
@@ -34,7 +34,7 @@ def render_tab():
|
|
| 34 |
|
| 35 |
<h3 class="wt wt-2">Optional:</h3>
|
| 36 |
<ol start="2">
|
| 37 |
-
<li><b>Method constraints</b>: select which carbon removal methods are active and set their <b>maximum deployment cap</b> (as % of total potential or absolute value in {CO2_UNIT}/yr).</li>
|
| 38 |
<li><b>Resource efficiency coefficients</b>: adjust the resource usage for each method.</li>
|
| 39 |
</ol>
|
| 40 |
|
|
|
|
| 34 |
|
| 35 |
<h3 class="wt wt-2">Optional:</h3>
|
| 36 |
<ol start="2">
|
| 37 |
+
<li><b>Method constraints</b>: select which carbon removal methods are active and set their <b>maximum deployment cap</b> (as % of its total potential or absolute value in {CO2_UNIT}/yr).</li>
|
| 38 |
<li><b>Resource efficiency coefficients</b>: adjust the resource usage for each method.</li>
|
| 39 |
</ol>
|
| 40 |
|
tabs/tab2_constraints.py
CHANGED
|
@@ -274,7 +274,7 @@ def render_method(m, cols, i, cost_sliders_norm):
|
|
| 274 |
["percent", "absolute"],
|
| 275 |
horizontal=True,
|
| 276 |
key=cap_type_key,
|
| 277 |
-
format_func=lambda x: "% of total potential"
|
| 278 |
if x == "percent"
|
| 279 |
else f"Absolute ({CO2_UNIT}/yr)",
|
| 280 |
label_visibility="collapsed",
|
|
|
|
| 274 |
["percent", "absolute"],
|
| 275 |
horizontal=True,
|
| 276 |
key=cap_type_key,
|
| 277 |
+
format_func=lambda x: "% of its total potential"
|
| 278 |
if x == "percent"
|
| 279 |
else f"Absolute ({CO2_UNIT}/yr)",
|
| 280 |
label_visibility="collapsed",
|
tabs/tab4_optimization.py
CHANGED
|
@@ -984,7 +984,7 @@ def export_scenario():
|
|
| 984 |
method_constraints_export = scenario.get("method_constraints", {})
|
| 985 |
alloc_df["Cap constraint"] = pd.Series({
|
| 986 |
m: (f"{c['cap_value']:,g} {CO2_UNIT}/yr" if c.get("cap_type") == "absolute"
|
| 987 |
-
else f"{int(c.get('cap_value', 100))}% of potential")
|
| 988 |
for m, c in method_constraints_export.items()
|
| 989 |
})
|
| 990 |
alloc_df = alloc_df.sort_values(f"{CO2_UNIT} removed", ascending=False)
|
|
|
|
| 984 |
method_constraints_export = scenario.get("method_constraints", {})
|
| 985 |
alloc_df["Cap constraint"] = pd.Series({
|
| 986 |
m: (f"{c['cap_value']:,g} {CO2_UNIT}/yr" if c.get("cap_type") == "absolute"
|
| 987 |
+
else f"{int(c.get('cap_value', 100))}% of its potential")
|
| 988 |
for m, c in method_constraints_export.items()
|
| 989 |
})
|
| 990 |
alloc_df = alloc_df.sort_values(f"{CO2_UNIT} removed", ascending=False)
|
tabs/tab5_sensitivity.py
CHANGED
|
@@ -153,7 +153,7 @@ def panel_method_sensitivity(baseline_removed):
|
|
| 153 |
)
|
| 154 |
st.markdown(
|
| 155 |
'<p class="caption">Each line shows how total CO₂ removal changes as one method\'s maximum deployment cap varies from 0% to 100% of its current setting: '
|
| 156 |
-
'whether that cap was defined as a percentage of potential or as an absolute value in the Method constraints tab. '
|
| 157 |
'<br>The scale stops at 100% because the cap represents a ceiling you set: going beyond it would mean overriding your own constraint. '
|
| 158 |
'<br>A steep curve means the cap is a binding constraint: relaxing it would allow more removal. '
|
| 159 |
'<br>A flat line means the method is already limited by resource availability, not by its cap.</p>',
|
|
@@ -220,7 +220,7 @@ def panel_method_sensitivity(baseline_removed):
|
|
| 220 |
if cap_type == "percent":
|
| 221 |
actual_cap = min(100, current_cap * pct / 100)
|
| 222 |
perturbed_constraints[method]["cap_value"] = actual_cap
|
| 223 |
-
cap_label = f"{actual_cap:.0f}% of potential"
|
| 224 |
else:
|
| 225 |
actual_cap = current_cap * pct / 100
|
| 226 |
perturbed_constraints[method]["cap_value"] = actual_cap
|
|
|
|
| 153 |
)
|
| 154 |
st.markdown(
|
| 155 |
'<p class="caption">Each line shows how total CO₂ removal changes as one method\'s maximum deployment cap varies from 0% to 100% of its current setting: '
|
| 156 |
+
'whether that cap was defined as a percentage of its potential or as an absolute value in the Method constraints tab. '
|
| 157 |
'<br>The scale stops at 100% because the cap represents a ceiling you set: going beyond it would mean overriding your own constraint. '
|
| 158 |
'<br>A steep curve means the cap is a binding constraint: relaxing it would allow more removal. '
|
| 159 |
'<br>A flat line means the method is already limited by resource availability, not by its cap.</p>',
|
|
|
|
| 220 |
if cap_type == "percent":
|
| 221 |
actual_cap = min(100, current_cap * pct / 100)
|
| 222 |
perturbed_constraints[method]["cap_value"] = actual_cap
|
| 223 |
+
cap_label = f"{actual_cap:.0f}% of its potential"
|
| 224 |
else:
|
| 225 |
actual_cap = current_cap * pct / 100
|
| 226 |
perturbed_constraints[method]["cap_value"] = actual_cap
|
utils/data_utils.py
CHANGED
|
@@ -598,7 +598,7 @@ def build_results_df(allocations: dict, method_constraints: dict | None = None)
|
|
| 598 |
if c.get("cap_type") == "absolute":
|
| 599 |
caps[m] = f"{c['cap_value']:,g} {CO2_UNIT}/yr"
|
| 600 |
elif c.get("cap_type") == "percent":
|
| 601 |
-
caps[m] = f"{int(c.get('cap_value', 100))}% of potential"
|
| 602 |
else:
|
| 603 |
caps[m] = f"⚠️ Invalid type ({c.get('cap_type')!r}) — fix in Tab 2"
|
| 604 |
df["Cap"] = pd.Series(caps)
|
|
|
|
| 598 |
if c.get("cap_type") == "absolute":
|
| 599 |
caps[m] = f"{c['cap_value']:,g} {CO2_UNIT}/yr"
|
| 600 |
elif c.get("cap_type") == "percent":
|
| 601 |
+
caps[m] = f"{int(c.get('cap_value', 100))}% of its potential"
|
| 602 |
else:
|
| 603 |
caps[m] = f"⚠️ Invalid type ({c.get('cap_type')!r}) — fix in Tab 2"
|
| 604 |
df["Cap"] = pd.Series(caps)
|