CarlyneB commited on
Commit
358d27e
·
1 Parent(s): d3a0799

[FIX] Resolve percent caps against each method's standalone potential

Browse files

Previously, "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 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)