Spaces:
Sleeping
Sleeping
File size: 15,449 Bytes
fa58ff0 7b4d13c 7a80c0b b98d5cb fa58ff0 7a80c0b fa58ff0 b98d5cb fa58ff0 b98d5cb fa58ff0 36d3ed3 fa58ff0 4881129 fa58ff0 783b23b fa58ff0 7a80c0b fa58ff0 b98d5cb fa58ff0 b98d5cb fa58ff0 36d3ed3 fa58ff0 4881129 358d27e 4881129 b98d5cb 7a80c0b b98d5cb 36d3ed3 b98d5cb 36d3ed3 b98d5cb fa58ff0 b98d5cb fa58ff0 783b23b fa58ff0 b98d5cb 36d3ed3 fa58ff0 b98d5cb fa58ff0 358d27e fa58ff0 7a80c0b fa58ff0 b98d5cb fa58ff0 4881129 7a80c0b 4881129 36f6d34 4881129 fa58ff0 36d3ed3 fa58ff0 7b4d13c fa58ff0 4881129 fa58ff0 20f0fcc 4881129 20f0fcc | 1 2 3 4 5 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 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 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | import copy
import pandas as pd
import streamlit as st
import plotly.express as px
from config.config import CO2_UNIT, hidden_resource_names, method_order, resource_order, resource_unit_map, section_intros
from utils.data_utils import (get_resource_to_unit, get_resource_to_group, run_expanded_optimisation,
is_other_method, get_other_method_variants, make_variant_name,
default_constraint)
from utils.ui_helpers import register_chart_data, show_bar
def compute_baseline():
"""Run the baseline optimisation and build combined resource metadata.
Returns:
tuple[float, list[str], dict[str, str]]:
baseline_removed โ total COโ removed at current inputs (0 if failed).
all_resources โ standard resources + custom batch names.
resource_to_unit โ {resource: unit} including custom batches.
"""
baseline_success, baseline_result = run_expanded_optimisation(
resource_caps=st.session_state.resource_caps,
method_constraints=st.session_state.constraints,
costs=st.session_state.costs,
custom_resources=st.session_state.get("custom_resources", []),
enabled_methods=st.session_state.get("enabled_methods", {}),
)
baseline_removed = baseline_result["total_removed"] if baseline_success else 0
# Only offer resources that actually have a quantity set โ sweeping a
# resource currently at 0 always gives 0 regardless of the % slider
# (any percentage of a 0 base cap is still 0), so it's never useful to
# compare.
custom_resource_names = [
b["name"] for b in st.session_state.get("custom_resources", [])
if float(b.get("amount", 0)) > 0
]
all_resources = [
r for r in resource_order
if r not in hidden_resource_names and st.session_state.resource_caps.get(r, 0) > 0
] + custom_resource_names
resource_to_unit = get_resource_to_unit()
for b in st.session_state.get("custom_resources", []):
resource_to_unit[b["name"]] = b.get("unit", "")
return baseline_removed, all_resources, resource_to_unit
def panel_resource_sensitivity(baseline_removed, all_resources, resource_to_unit):
"""Render the resource-availability sensitivity sub-panel.
Sweeps each selected resource from 0 % to 200 % of its current cap in
10 % steps, re-solves the LP at each point, and plots total COโ removed.
Args:
baseline_removed (float): total COโ removed at current inputs.
all_resources (list[str]): standard + custom resource names to select from.
resource_to_unit (dict[str, str]): {resource: unit} for axis labels.
Returns:
None
"""
st.markdown(
'<p class="crra-sub-heading">Impact of resource availability</p>',
unsafe_allow_html=True,
)
st.markdown(
'<p class="caption">Each line shows how total COโ removal changes as one resource\'s availability varies from 0% to 200% of its current cap. '
'<br>The range goes up to 200% so you can see both the impact of a reduction and the potential gains from doubling availability. '
'<br>A steep curve means that resource strongly drives portfolio performance: small increases can yield large gains. '
'<br>A flat line means the portfolio is limited by something else (another resource or a method cap).</p>',
unsafe_allow_html=True,
)
selected_resources = st.multiselect(
"Select one or more resources:", all_resources, key="crra-select-resource"
)
if not selected_resources:
st.warning("Please select at least one resource to run the sensitivity analysis.", icon=":material/warning:")
return
percentages = list(range(0, 210, 10))
custom_amount_by_name = {
b["name"]: float(b.get("amount", 0))
for b in st.session_state.get("custom_resources", [])
}
all_data = []
for resource in selected_resources:
unit = resource_to_unit.get(resource, "")
base_cap = (
st.session_state.resource_caps.get(resource)
or custom_amount_by_name.get(resource, 0)
)
for pct in percentages:
perturbed_caps = st.session_state.resource_caps.copy()
actual_amount = base_cap * pct / 100
perturbed_caps[resource] = actual_amount
success, result = run_expanded_optimisation(
resource_caps=perturbed_caps,
method_constraints=st.session_state.constraints,
costs=st.session_state.costs,
custom_resources=st.session_state.get("custom_resources", []),
enabled_methods=st.session_state.get("enabled_methods", {}),
)
all_data.append({
"Input": resource,
"% of cap": pct,
"Actual amount": round(actual_amount, 4),
"Unit": unit,
f"COโ removed ({CO2_UNIT})": result["total_removed"] if success else 0,
})
df = pd.DataFrame(all_data)
st.markdown("#### Sensitivity to resource availability")
fig = px.line(
df, x="% of cap", y=f"COโ removed ({CO2_UNIT})", color="Input", markers=True,
hover_data={"Actual amount": ":.4f", "Unit": True},
)
fig.update_layout(
xaxis_title="Resource availability (% of current cap)",
yaxis_title=f"Total portfolio COโ removal ({CO2_UNIT})",
)
fig.add_hline(
y=baseline_removed, line_dash="dash", line_color="gray",
annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}",
)
st.plotly_chart(fig, width='stretch')
with st.expander("๐ View / download chart data"):
st.dataframe(df, width='stretch', hide_index=True)
register_chart_data("Sensitivity to resource availability", df)
def panel_method_sensitivity(baseline_removed):
"""Render the method-cap sensitivity sub-panel.
Sweeps each selected method's cap from 0 % to 100 % of its current value
in 10 % steps, re-solves the LP at each point, and plots total COโ removed.
Methods whose current cap is already 0 are excluded from the selector
entirely (sweeping "X% of 0" is always 0, never useful to compare).
Args:
baseline_removed (float): total COโ removed at current inputs.
Returns:
None
"""
st.markdown(
'<p class="crra-sub-heading">Impact of max allocation per method</p>',
unsafe_allow_html=True,
)
st.markdown(
'<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: '
'whether that cap was defined as a percentage of its potential or as an absolute value in the Method constraints tab. '
'<br>The scale stops at 100% because the cap represents a ceiling you set: going beyond it would mean overriding your own constraint. '
'<br>A steep curve means the cap is a binding constraint: relaxing it would allow more removal. '
'<br>A flat line means the method is already limited by resource availability, not by its cap.</p>',
unsafe_allow_html=True,
)
# "Other"-tagged methods with custom batches attached are expanded into
# independent variants (e.g. "Enhanced weathering - Other mineral: Ganite")
# by run_expanded_optimisation โ offer those variants as selectable
# entries too, alongside the standard method names, so their own cap can
# be swept just like any other method. An "Other"-tagged method with NO
# batch attached has no variant AND can never produce any result on its
# own (its only resource is the hidden "Other X" pool, which is never
# directly settable) โ skip it entirely rather than offer a sweep that
# would always show a flat 0 line.
#
# Same reasoning as the resource panel: a method whose current cap is
# already 0 would sweep to "X% of 0", always 0 regardless of the slider โ
# never useful to compare โ so it's excluded from the list up front
# instead of being selectable and only warned about afterwards. Same for
# a method that is simply inactive (missing resources, manually toggled
# off in Tab 2...): sweeping its cap_value doesn't touch "active", so the
# perturbed run stays forced to 0 regardless โ just as pointless to offer.
def _current_cap(method_name):
constraint = st.session_state.constraints.get(method_name) or default_constraint()
cap_type = constraint.get("cap_type", "percent")
return constraint.get("cap_value", 100 if cap_type == "percent" else 0.0)
def _is_sweepable(method_name):
constraint = st.session_state.constraints.get(method_name) or default_constraint()
return constraint.get("active", True) and _current_cap(method_name) > 0
custom_resources = st.session_state.get("custom_resources", [])
method_options = []
for m in method_order:
variants = get_other_method_variants(m, custom_resources)
if variants:
method_options.extend(
make_variant_name(m, b["name"]) for b in variants
if _is_sweepable(make_variant_name(m, b["name"]))
)
elif not is_other_method(m) and _is_sweepable(m):
method_options.append(m)
selected_methods = st.multiselect(
"Select one or more methods:", method_options, key="crra-select-method"
)
if not selected_methods:
st.warning("Please select at least one method to run the sensitivity analysis.", icon=":material/warning:")
return
percentages = list(range(0, 110, 10))
all_data = []
for method in selected_methods:
constraint = st.session_state.constraints.get(method) or default_constraint()
cap_type = constraint.get("cap_type", "percent")
current_cap = _current_cap(method)
for pct in percentages:
perturbed_constraints = copy.deepcopy(st.session_state.constraints)
perturbed_constraints.setdefault(method, default_constraint())
if cap_type == "percent":
actual_cap = min(100, current_cap * pct / 100)
perturbed_constraints[method]["cap_value"] = actual_cap
cap_label = f"{actual_cap:.0f}% of its potential"
else:
actual_cap = current_cap * pct / 100
perturbed_constraints[method]["cap_value"] = actual_cap
cap_label = f"{actual_cap:.2f} {CO2_UNIT}/yr"
success, result = run_expanded_optimisation(
resource_caps=st.session_state.resource_caps,
method_constraints=perturbed_constraints,
costs=st.session_state.costs,
custom_resources=custom_resources,
enabled_methods=st.session_state.get("enabled_methods", {}),
)
all_data.append({
"Method": method,
"Cap type": cap_type,
"% of current cap": pct,
"Actual cap": cap_label,
f"COโ removed ({CO2_UNIT})": result["total_removed"] if success else 0,
})
if not all_data:
return
df = pd.DataFrame(all_data)
fig = px.line(
df, x="% of current cap", y=f"COโ removed ({CO2_UNIT})",
color="Method", markers=True,
hover_data={"Actual cap": True, "Cap type": True},
)
fig.add_hline(
y=baseline_removed, line_dash="dash", line_color="lightgray",
annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}",
)
fig.update_layout(
xaxis_title="% of current cap setting",
yaxis_title=f"Total portfolio COโ removal ({CO2_UNIT})",
)
st.plotly_chart(fig, width='stretch')
with st.expander("๐ View / download chart data"):
st.dataframe(df, width='stretch', hide_index=True)
register_chart_data("Sensitivity to method cap", df)
def show_bottleneck_analysis():
"""Render the resource sensitivity table (CDR gain per +1 unit of each resource).
Reads the latest optimisation result from session state. Shows nothing if no
result is available yet.
Returns:
None
"""
latest = st.session_state.get("latest_result")
if not latest:
st.info("Run the optimisation first (Portfolio generation tab) to see the bottleneck analysis.", icon=":material/info:")
return
st.markdown('<p class="crra-sub-heading">Resource sensitivity</p>', unsafe_allow_html=True)
st.markdown(
'<p class="caption">'
'For each resource: the additional COโ that could be removed by adding exactly +1 unit, '
'recomputed with all other constraints held fixed. '
'<br>Resources at the top of the list are the most binding bottlenecks in your current portfolio: '
'prioritise increasing their availability to unlock the largest gains.'
'</p>',
unsafe_allow_html=True,
)
resource_to_group = get_resource_to_group()
custom_batch_lookup = {b["name"]: b for b in latest.get("custom_resources", [])}
actual_gains = latest.get("resource_actual_gain", {})
if actual_gains:
sp_rows = []
for r, gain in sorted(actual_gains.items(), key=lambda x: -x[1]):
if r in custom_batch_lookup:
unit = resource_unit_map.get(resource_to_group.get(custom_batch_lookup[r]["group"]), "?")
else:
unit = resource_unit_map.get(resource_to_group.get(r), "?")
sp_rows.append({
"Resource": r,
"Unit": unit,
f"CDR gain for +1 unit ({CO2_UNIT})": round(gain, 4),
})
df_sp = pd.DataFrame(sp_rows)
st.dataframe(df_sp, width='stretch', hide_index=True)
register_chart_data("Resource sensitivity (CDR gain per +1 unit)", df_sp)
else:
st.info("No resources to analyse.", icon=":material/info:")
def render_tab():
"""Render the Sensitivity Analysis tab.
Computes a baseline optimisation from session state, then lets the user
sweep one or more resources (or method caps) over a range and plots how
total COโ removed changes. resource_caps, constraints, and costs are
guaranteed present by app.py's shared init, which runs before any tab.
Returns:
None
"""
st.markdown('<h1 class="crra-heading">Sensitivity analysis</h1>', unsafe_allow_html=True)
show_bar()
st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True)
st.markdown(
f'<div class="text_with_border"><p>{section_intros.get("tab5_sensitivity", "")}</p></div>',
unsafe_allow_html=True,
)
baseline_removed, all_resources, resource_to_unit = compute_baseline()
tab1, tab2, tab3 = st.tabs(["๐ Resource sensitivity", "๐ Method sensitivity", "๐ Bottleneck analysis"])
with tab1:
panel_resource_sensitivity(baseline_removed, all_resources, resource_to_unit)
with tab2:
panel_method_sensitivity(baseline_removed)
with tab3:
show_bottleneck_analysis()
if st.button("Scenario comparison โ", key="btn-nav-next-sensitivity", type="primary", help="Compare multiple saved scenarios side by side."):
st.session_state["_navigate_to"] = "Scenario comparison"
st.rerun() |