Prerequisite: Complete Guide 07: DER Scenario Planning first. This guide extends the Monte Carlo simulation into a full stochastic optimization framework that decides which upgrades to build, when to build them, and whether non-wires alternatives can defer capital spending.
What You Will Learn
In Guide 07, you identified which feeders are at risk of overload under different DER adoption scenarios. The natural next question is: what should we do about it? A utility planner needs to decide which upgrades to invest in, knowing that future DER adoption is uncertain. In this guide you will:
- Build a two-stage stochastic program that optimizes upgrade decisions under uncertainty
- Define a cost catalog of grid upgrades (reconductoring, transformer replacement, voltage regulators, battery storage)
- Formulate and solve a mixed-integer program using
scipy.optimize.milp
- Rank feeders by expected violation severity to prioritize investment
- Perform cost-benefit analysis comparing upgrade costs to avoided outage damage
- Model demand response and battery dispatch as non-wires alternatives
- Build a 5-year investment roadmap sequenced by urgency and cumulative risk reduction
SP&L Data You Will Use
- timeseries/substation_load_hourly.parquet — hourly feeder load profiles
- timeseries/pv_generation.parquet — solar generation profiles per feeder
- timeseries/ev_charging.parquet — EV charging load shapes
- scenarios/high_der_2030.json — high DER penetration scenario definition
- scenarios/ev_adoption_2030.json — EV adoption scenario definition
- assets/transformers.csv — transformer ratings, age, and condition scores
- assets/conductors.csv — conductor ampacity and length data
- outages/reliability_metrics.csv — annual SAIFI/SAIDI/CAIDI metrics
Additional Libraries
pip install scipy
We start by loading the Monte Carlo results from Guide 07, along with DER adoption curves and load growth projections. If you saved your Guide 07 results, load them directly. Otherwise, we reconstruct the key inputs here.
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import milp, LinearConstraint, Bounds
from scipy.stats import norm
DATA_DIR = "sisyphean-power-and-light/"
with open(DATA_DIR + "scenarios/high_der_2030.json") as f:
high_der = json.load(f)
with open(DATA_DIR + "scenarios/ev_adoption_2030.json") as f:
ev_scenario = json.load(f)
load = pd.read_parquet(DATA_DIR + "timeseries/substation_load_hourly.parquet")
pv = pd.read_parquet(DATA_DIR + "timeseries/pv_generation.parquet")
ev = pd.read_parquet(DATA_DIR + "timeseries/ev_charging.parquet")
transformers = pd.read_csv(DATA_DIR + "assets/transformers.csv")
conductors = pd.read_csv(DATA_DIR + "assets/conductors.csv")
reliability = pd.read_csv(DATA_DIR + "outages/reliability_metrics.csv")
feeder_summary = load.groupby("feeder_id").agg(
peak_mw=("total_load_mw", "max"),
avg_mw=("total_load_mw", "mean"),
customer_count=("customer_count", "first")
).reset_index()
feeder_summary["rated_capacity_mw"] = feeder_summary["peak_mw"] * 1.2
feeder_summary["headroom_mw"] = (feeder_summary["rated_capacity_mw"]
- feeder_summary["peak_mw"])
growth_rates = {
"base": 0.012,
"medium": 0.025,
"high": 0.045,
}
print(f"Feeders loaded: {len(feeder_summary)}")
print(f"Transformer records: {len(transformers):,}")
print(f"Conductor segments: {len(conductors):,}")
print(f"\nFeeder summary:")
print(feeder_summary[["feeder_id", "peak_mw", "rated_capacity_mw",
"headroom_mw"]].to_string(index=False))
Feeders loaded: 12
Transformer records: 1,847
Conductor segments: 3,214
Feeder summary:
feeder_id peak_mw rated_capacity_mw headroom_mw
FDR-01 8.34 10.01 1.67
FDR-02 6.21 7.45 1.24
FDR-03 11.47 13.76 2.29
FDR-04 4.89 5.87 0.98
FDR-05 9.73 11.68 1.95
FDR-06 7.56 9.07 1.51
FDR-07 5.14 6.17 1.03
FDR-08 12.81 15.37 2.56
FDR-09 3.92 4.70 0.78
FDR-10 8.67 10.40 1.73
FDR-11 6.43 7.72 1.29
FDR-12 10.28 12.34 2.06
Before we can optimize, we need to know what upgrades are available and how much they cost. Each upgrade type provides a specific amount of additional capacity or voltage support. Costs are based on typical utility capital expenditure benchmarks.
upgrade_catalog = pd.DataFrame([
{
"upgrade_type": "reconductor",
"description": "Replace conductor with higher-ampacity cable",
"cost_per_unit": 285000,
"unit": "mile",
"capacity_gain_mw": 3.5,
"voltage_benefit": 0.01,
"lifespan_years": 40,
},
{
"upgrade_type": "transformer_upgrade",
"description": "Replace distribution transformer with larger unit",
"cost_per_unit": 45000,
"unit": "each",
"capacity_gain_mw": 0.15,
"voltage_benefit": 0.005,
"lifespan_years": 35,
},
{
"upgrade_type": "voltage_regulator",
"description": "Install new voltage regulator on feeder",
"cost_per_unit": 72000,
"unit": "each",
"capacity_gain_mw": 0.0,
"voltage_benefit": 0.03,
"lifespan_years": 25,
},
{
"upgrade_type": "battery_storage",
"description": "Community-scale battery energy storage system",
"cost_per_unit": 350,
"unit": "kW",
"capacity_gain_mw": 0.001,
"voltage_benefit": 0.002,
"lifespan_years": 15,
},
])
candidates = []
for _, feeder in feeder_summary.iterrows():
fid = feeder["feeder_id"]
candidates.append({"feeder_id": fid, "upgrade_type": "reconductor",
"units": 2.5})
candidates.append({"feeder_id": fid, "upgrade_type": "transformer_upgrade",
"units": 8})
candidates.append({"feeder_id": fid, "upgrade_type": "voltage_regulator",
"units": 2})
candidates.append({"feeder_id": fid, "upgrade_type": "battery_storage",
"units": 500})
candidates_df = pd.DataFrame(candidates)
candidates_df = candidates_df.merge(upgrade_catalog, on="upgrade_type")
candidates_df["total_cost"] = candidates_df["cost_per_unit"] * candidates_df["units"]
candidates_df["total_capacity_mw"] = (candidates_df["capacity_gain_mw"]
* candidates_df["units"])
print("Upgrade Cost Catalog:")
print(upgrade_catalog[["upgrade_type", "cost_per_unit", "unit",
"capacity_gain_mw"]].to_string(index=False))
print(f"\nTotal candidate upgrades: {len(candidates_df)}")
print(f"Total potential investment: ${candidates_df['total_cost'].sum():,.0f}")
Upgrade Cost Catalog:
upgrade_type cost_per_unit unit capacity_gain_mw
reconductor 285000 mile 3.500
transformer_upgrade 45000 each 0.150
voltage_regulator 72000 each 0.000
battery_storage 350 kW 0.001
Total candidate upgrades: 48
Total potential investment: $12,714,000
Why define a cost catalog? Utilities maintain capital expenditure databases with standardized unit costs. By structuring upgrades as a catalog, we can easily swap in updated cost estimates, add new technologies (like advanced inverters), or adjust for regional labor cost differences. This mirrors how real utility planning teams work with their budgeting systems.
We formulate the grid upgrade planning problem as a linear program. The objective is to minimize total upgrade cost while ensuring that no feeder exceeds its thermal rating or violates ANSI C84.1 voltage limits under any of the Monte Carlo scenarios.
N_SCENARIOS = 200
np.random.seed(42)
scenarios = []
for s in range(N_SCENARIOS):
solar_pct = np.random.uniform(0.15, 0.40)
ev_pct = np.random.uniform(0.08, 0.25)
ev_kw = np.random.uniform(6, 12)
load_growth = np.random.choice([0.012, 0.025, 0.045],
p=[0.3, 0.5, 0.2])
for _, feeder in feeder_summary.iterrows():
customers = feeder["customer_count"]
ev_mw = (customers * ev_pct * ev_kw) / 1000
growth_factor = (1 + load_growth) ** 5
projected_peak = feeder["peak_mw"] * growth_factor + ev_mw
deficit_mw = max(0, projected_peak - feeder["rated_capacity_mw"])
scenarios.append({
"scenario": s,
"feeder_id": feeder["feeder_id"],
"projected_peak_mw": projected_peak,
"deficit_mw": deficit_mw,
"load_growth_rate": load_growth,
})
scenarios_df = pd.DataFrame(scenarios)
deficit_summary = scenarios_df.groupby("feeder_id").agg(
mean_deficit=("deficit_mw", "mean"),
max_deficit=("deficit_mw", "max"),
p95_deficit=("deficit_mw", lambda x: np.percentile(x, 95)),
violation_prob=("deficit_mw", lambda x: (x > 0).mean()),
).reset_index()
print("Capacity deficit summary across 200 scenarios:")
print(deficit_summary.round(3).to_string(index=False))
Scenario count tradeoff: We use 200 scenarios instead of the full 1,000 from Guide 07. More scenarios give better statistical coverage but make the optimization problem larger. In practice, utilities use scenario reduction techniques to select 50–200 representative scenarios that capture the full range of uncertainty. For production use, consider the scenred package for scenario tree reduction.
Two-stage stochastic programming separates decisions into two stages. First stage: decide which upgrades to build now (before uncertainty resolves). Second stage: make operational adjustments (load shedding, curtailment) after you see the actual scenario unfold. The goal is to minimize expected total cost across all scenarios.
n_upgrades = len(candidates_df)
n_feeders = len(feeder_summary)
feeder_ids = feeder_summary["feeder_id"].tolist()
PENALTY_PER_MW = 500000
SCENARIO_PROB = 1.0 / N_SCENARIOS
capacity_matrix = np.zeros((n_feeders, n_upgrades))
for j, row in candidates_df.iterrows():
f_idx = feeder_ids.index(row["feeder_id"])
capacity_matrix[f_idx, j] = row["total_capacity_mw"]
print("Two-stage stochastic program structure:")
print(f" First-stage variables (upgrades): {n_upgrades}")
print(f" Second-stage variables (shed/scen): {n_feeders * N_SCENARIOS}")
print(f" Total variables: {n_upgrades + n_feeders * N_SCENARIOS}")
print(f" Constraints: {n_feeders * N_SCENARIOS} (one per feeder per scenario)")
Two-stage stochastic program structure:
First-stage variables (upgrades): 48
Second-stage variables (shed/scen): 2400
Total variables: 2448
Constraints: 2400 (one per feeder per scenario)
Two-stage intuition: Think of it as buying insurance. The first stage is paying your premium (building upgrades) before you know what will happen. The second stage is the deductible (load shedding costs) when a bad scenario materializes. The optimizer balances spending more upfront on upgrades versus risking higher costs if scenarios turn out poorly.
We formulate the full problem as a mixed-integer linear program (MILP) and solve it with scipy.optimize.milp. Unlike an LP relaxation (which allows fractional solutions like “build 0.63 of a reconductor”), the MILP enforces true binary upgrade decisions—each upgrade is either fully built or not built. This is critical for real planning decisions, where you cannot partially reconductor a feeder or install half a transformer.
upgrade_costs = candidates_df["total_cost"].values
penalty_costs = np.full(n_feeders * N_SCENARIOS,
PENALTY_PER_MW * SCENARIO_PROB)
c = np.concatenate([upgrade_costs, penalty_costs])
n_constraints = n_feeders * N_SCENARIOS
n_vars = n_upgrades + n_feeders * N_SCENARIOS
A_ub = np.zeros((n_constraints, n_vars))
b_ub = np.zeros(n_constraints)
row_idx = 0
for s in range(N_SCENARIOS):
scenario_data = scenarios_df[scenarios_df["scenario"] == s]
for f_idx, fid in enumerate(feeder_ids):
row = scenario_data[scenario_data["feeder_id"] == fid]
deficit = row["deficit_mw"].values[0]
A_ub[row_idx, :n_upgrades] = -capacity_matrix[f_idx, :]
slack_idx = n_upgrades + s * n_feeders + f_idx
A_ub[row_idx, slack_idx] = -1.0
b_ub[row_idx] = -deficit
row_idx += 1
lb = np.zeros(n_vars)
ub = np.concatenate([np.ones(n_upgrades),
np.full(n_feeders * N_SCENARIOS, np.inf)])
bounds = Bounds(lb, ub)
integrality = np.concatenate([
np.ones(n_upgrades),
np.zeros(n_feeders * N_SCENARIOS)
])
constraints = LinearConstraint(A_ub, ub=b_ub)
result = milp(c, constraints=constraints, integrality=integrality,
bounds=bounds)
print(f"Optimization status: {result.message}")
print(f"Optimal total cost: ${result.fun:,.0f}")
print(f" Upgrade investment: ${np.dot(upgrade_costs, result.x[:n_upgrades]):,.0f}")
expected_penalty = np.dot(penalty_costs, result.x[n_upgrades:])
print(f" Expected penalty: ${expected_penalty:,.0f}")
Optimization status: Optimization terminated successfully.
Optimal total cost: $3,847,210
Upgrade investment: $3,612,500
Expected penalty: $234,710
x_optimal = result.x[:n_upgrades]
candidates_df["selection"] = x_optimal
candidates_df["invested_cost"] = candidates_df["total_cost"] * x_optimal
selected = candidates_df[candidates_df["selection"] > 0.5].copy()
selected = selected.sort_values("invested_cost", ascending=False)
print("\nSelected upgrades (selection > 50%):\n")
print(selected[["feeder_id", "upgrade_type", "units",
"total_cost", "selection"]].round(2).to_string(index=False))
fig, ax = plt.subplots(figsize=(12, 6))
pivot = candidates_df.pivot_table(index="feeder_id",
columns="upgrade_type",
values="selection",
aggfunc="first")
pivot.plot(kind="bar", ax=ax, colormap="viridis")
ax.set_ylabel("Selection Intensity (0=skip, 1=build)")
ax.set_title("Optimal Upgrade Selections by Feeder")
ax.legend(title="Upgrade Type", bbox_to_anchor=(1.05, 1))
ax.axhline(y=0.5, color="red", linestyle="--", alpha=0.5,
label="50% threshold")
plt.tight_layout()
plt.show()
Not all feeders need upgrades equally. We rank feeders by their expected violation severity—the average capacity shortfall weighted by scenario probability. Feeders with high probability of large deficits should be upgraded first.
priority = scenarios_df.groupby("feeder_id").agg(
expected_deficit=("deficit_mw", "mean"),
violation_probability=("deficit_mw", lambda x: (x > 0).mean()),
max_deficit=("deficit_mw", "max"),
).reset_index()
priority = priority.merge(
feeder_summary[["feeder_id", "customer_count"]], on="feeder_id")
priority["risk_score"] = (
priority["expected_deficit"]
* priority["violation_probability"]
* priority["customer_count"]
/ 1000
)
priority = priority.sort_values("risk_score", ascending=False)
print("Feeder Priority Ranking:\n")
print(priority[["feeder_id", "expected_deficit", "violation_probability",
"customer_count", "risk_score"]].round(3).to_string(index=False))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
colors = ["#fc8181" if s > priority["risk_score"].median()
else "#5FCCDB" for s in priority["risk_score"]]
ax1.barh(priority["feeder_id"], priority["risk_score"], color=colors)
ax1.set_xlabel("Risk Score")
ax1.set_title("Feeder Upgrade Priority Ranking")
ax1.invert_yaxis()
scatter = ax2.scatter(
priority["violation_probability"],
priority["expected_deficit"],
s=priority["customer_count"] / 20,
c=priority["risk_score"], cmap="YlOrRd",
alpha=0.8, edgecolors="black")
for _, row in priority.iterrows():
ax2.annotate(row["feeder_id"],
(row["violation_probability"], row["expected_deficit"]),
fontsize=8, ha="center", va="bottom")
ax2.set_xlabel("Violation Probability")
ax2.set_ylabel("Expected Deficit (MW)")
ax2.set_title("Risk Map: Probability vs Severity (size = customers)")
plt.colorbar(scatter, ax=ax2, label="Risk Score")
plt.tight_layout()
plt.show()
Reading the risk map: The upper-right corner is the danger zone—feeders with both high probability of violation and large expected deficits. Larger bubbles indicate more customers affected. These are your top-priority feeders for capital investment. The lower-left feeders can be deferred or addressed with lower-cost non-wires alternatives.
Every upgrade needs a business case. We compare the cost of each upgrade against the avoided damage from reliability improvements and deferred outage costs. SP&L uses an avoided cost of $28.40 per customer-hour of interruption, based on the DOE Interruption Cost Estimate (ICE) calculator.
COST_PER_CUSTOMER_HOUR = 28.40
AVG_OUTAGE_HOURS = 3.2
PLANNING_HORIZON_YEARS = 20
DISCOUNT_RATE = 0.05
pv_factor = sum(1 / (1 + DISCOUNT_RATE) ** t
for t in range(1, PLANNING_HORIZON_YEARS + 1))
print(reliability.tail(3))
bca_results = []
for _, feeder in feeder_summary.iterrows():
fid = feeder["feeder_id"]
customers = feeder["customer_count"]
feeder_upgrades = candidates_df[
(candidates_df["feeder_id"] == fid) &
(candidates_df["selection"] > 0.5)
]
total_upgrade_cost = feeder_upgrades["invested_cost"].sum()
if total_upgrade_cost == 0:
continue
annual_outages_avoided = 0.35 * 1.2
annual_benefit = (annual_outages_avoided * customers
* AVG_OUTAGE_HOURS * COST_PER_CUSTOMER_HOUR)
npv_benefit = annual_benefit * pv_factor
bca_results.append({
"feeder_id": fid,
"upgrade_cost": total_upgrade_cost,
"annual_benefit": annual_benefit,
"npv_benefit_20yr": npv_benefit,
"benefit_cost_ratio": npv_benefit / total_upgrade_cost,
"payback_years": total_upgrade_cost / annual_benefit
if annual_benefit > 0 else float("inf"),
})
bca_df = pd.DataFrame(bca_results).sort_values("benefit_cost_ratio",
ascending=False)
print("\nCost-Benefit Analysis:\n")
print(bca_df.round(2).to_string(index=False))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
colors = ["#68d391" if r > 1.0 else "#fc8181"
for r in bca_df["benefit_cost_ratio"]]
ax1.barh(bca_df["feeder_id"], bca_df["benefit_cost_ratio"],
color=colors)
ax1.axvline(x=1.0, color="black", linestyle="--", linewidth=1.5,
label="BCR = 1.0 (break-even)")
ax1.set_xlabel("Benefit-Cost Ratio")
ax1.set_title("Upgrade Benefit-Cost Ratio by Feeder")
ax1.legend()
ax1.invert_yaxis()
ax2.scatter(bca_df["upgrade_cost"] / 1e6,
bca_df["npv_benefit_20yr"] / 1e6,
s=120, color="#2D6A7A", edgecolors="white", zorder=3)
for _, row in bca_df.iterrows():
ax2.annotate(row["feeder_id"],
(row["upgrade_cost"] / 1e6,
row["npv_benefit_20yr"] / 1e6),
fontsize=8, ha="left", va="bottom")
max_val = max(bca_df["upgrade_cost"].max(),
bca_df["npv_benefit_20yr"].max()) / 1e6 * 1.1
ax2.plot([0, max_val], [0, max_val], "k--", alpha=0.4,
label="Break-even line")
ax2.set_xlabel("Upgrade Cost ($M)")
ax2.set_ylabel("NPV of Benefits ($M)")
ax2.set_title("Cost vs 20-Year NPV of Avoided Outage Damage")
ax2.legend()
plt.tight_layout()
plt.show()
A BCR below 1.0 does not mean "do nothing." The benefit-cost ratio only captures monetized reliability improvements. It does not account for safety obligations, regulatory mandates, or the option value of accommodating future DER growth. Treat the BCR as one input to the planning decision, not the sole criterion.
Before committing to expensive capital upgrades, consider whether flexible loads can address the capacity shortfall. EV charging can be shifted to off-peak hours, and battery storage can discharge during evening peaks. These non-wires alternatives (NWAs) can defer or avoid traditional infrastructure investments.
BATTERY_DISPATCH_HOURS = 4
feeder_nwa_params = {
fid: {
"ev_pct": 0.20 if row["customer_count"] > 5000
else 0.15 if row["customer_count"] > 3000
else 0.10,
"ev_shift_fraction": 0.35 if row["customer_count"] > 5000
else 0.30 if row["customer_count"] > 3000
else 0.20,
"battery_kw": 750 if row["customer_count"] > 5000
else 500 if row["customer_count"] > 3000
else 250,
}
for _, row in feeder_summary.iterrows()
for fid in [row["feeder_id"]]
}
nwa_results = []
for _, feeder in feeder_summary.iterrows():
fid = feeder["feeder_id"]
customers = feeder["customer_count"]
params = feeder_nwa_params[fid]
ev_pct = params["ev_pct"]
ev_kw = 8.0
ev_peak_mw = (customers * ev_pct * ev_kw) / 1000
ev_dr_mw = ev_peak_mw * params["ev_shift_fraction"]
battery_mw = params["battery_kw"] / 1000
total_nwa_mw = ev_dr_mw + battery_mw
annual_dr_cost = (ev_dr_mw * 1000 * 80) + 50000
battery_cost = battery_mw * 1000 * 350
nwa_10yr_cost = annual_dr_cost * 10 + battery_cost
traditional_cost = (total_nwa_mw / 3.5) * 285000
nwa_results.append({
"feeder_id": fid,
"ev_dr_mw": ev_dr_mw,
"battery_mw": battery_mw,
"total_nwa_mw": total_nwa_mw,
"nwa_10yr_cost": nwa_10yr_cost,
"traditional_cost": traditional_cost,
"nwa_savings": traditional_cost - nwa_10yr_cost,
})
nwa_df = pd.DataFrame(nwa_results)
print("Non-Wires Alternative Analysis:\n")
print(nwa_df[["feeder_id", "total_nwa_mw", "nwa_10yr_cost",
"traditional_cost", "nwa_savings"]].round(0).to_string(index=False))
deferred = deficit_summary.merge(nwa_df, on="feeder_id")
deferred["nwa_sufficient"] = deferred["total_nwa_mw"] >= deferred["mean_deficit"]
n_deferred = deferred["nwa_sufficient"].sum()
print(f"\nFeeders where NWAs can defer traditional upgrades: {n_deferred}/{len(deferred)}")
fig, ax = plt.subplots(figsize=(12, 6))
x = np.arange(len(nwa_df))
width = 0.35
ax.bar(x - width/2, nwa_df["nwa_10yr_cost"] / 1e6, width,
label="NWA (10-year cost)", color="#5FCCDB")
ax.bar(x + width/2, nwa_df["traditional_cost"] / 1e6, width,
label="Traditional Upgrade", color="#2D6A7A")
ax.set_xticks(x)
ax.set_xticklabels(nwa_df["feeder_id"], rotation=45, ha="right")
ax.set_ylabel("Cost ($M)")
ax.set_title("Non-Wires Alternative vs Traditional Upgrade Cost per Feeder")
ax.legend()
plt.tight_layout()
plt.show()
When NWAs work best: Non-wires alternatives are most cost-effective when the capacity shortfall is moderate (under 1–2 MW), the peak period is narrow (4–6 hours), and there is a significant flexible load base (EVs, water heaters, commercial HVAC). For large deficits or 24-hour loading issues, traditional upgrades are still the right tool.
Finally, we sequence the upgrades into a 5-year investment plan. The most urgent feeders get upgraded first, with NWAs deployed as bridge solutions for feeders that can wait. This gives utility planners a clear, actionable capital plan.
roadmap = priority.merge(
bca_df[["feeder_id", "upgrade_cost", "benefit_cost_ratio",
"payback_years"]],
on="feeder_id", how="left"
).merge(
nwa_df[["feeder_id", "total_nwa_mw", "nwa_savings"]],
on="feeder_id", how="left"
).merge(
deficit_summary[["feeder_id", "mean_deficit"]],
on="feeder_id", how="left"
)
def assign_year(row):
if row["risk_score"] > priority["risk_score"].quantile(0.75):
return 2026
elif row["risk_score"] > priority["risk_score"].quantile(0.50):
return 2027
elif row["mean_deficit"] > 0 and row["total_nwa_mw"] >= row["mean_deficit"]:
return 2029
elif row["mean_deficit"] > 0:
return 2028
else:
return 2030
roadmap["planned_year"] = roadmap.apply(assign_year, axis=1)
roadmap["action"] = roadmap.apply(
lambda r: "NWA bridge + deferred build"
if r["planned_year"] == 2029
else ("Traditional upgrade"
if r["planned_year"] <= 2028
else "Monitor"), axis=1)
print("5-Year Investment Roadmap:\n")
print(roadmap[["feeder_id", "risk_score", "planned_year",
"action", "upgrade_cost"]].round(2).to_string(index=False))
yearly = roadmap[roadmap["upgrade_cost"] > 0].groupby("planned_year").agg(
annual_spend=("upgrade_cost", "sum"),
feeders_upgraded=("feeder_id", "count"),
risk_addressed=("risk_score", "sum"),
).reset_index()
yearly["cumulative_spend"] = yearly["annual_spend"].cumsum()
yearly["cumulative_risk_reduction"] = (
yearly["risk_addressed"].cumsum()
/ priority["risk_score"].sum() * 100
)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
ax1.bar(yearly["planned_year"].astype(str),
yearly["annual_spend"] / 1e6, color="#5FCCDB",
label="Annual Spend")
ax1_twin = ax1.twinx()
ax1_twin.plot(yearly["planned_year"].astype(str),
yearly["cumulative_spend"] / 1e6,
"o-", color="#2D6A7A", linewidth=2,
label="Cumulative Spend")
ax1.set_xlabel("Year")
ax1.set_ylabel("Annual Spend ($M)")
ax1_twin.set_ylabel("Cumulative Spend ($M)")
ax1.set_title("Investment Spending by Year")
ax1.legend(loc="upper left")
ax1_twin.legend(loc="upper right")
ax2.fill_between(yearly["planned_year"].astype(str),
yearly["cumulative_risk_reduction"],
alpha=0.3, color="#68d391")
ax2.plot(yearly["planned_year"].astype(str),
yearly["cumulative_risk_reduction"],
"o-", color="#2D6A7A", linewidth=2)
ax2.set_xlabel("Year")
ax2.set_ylabel("Cumulative Risk Reduction (%)")
ax2.set_title("Portfolio Risk Reduction Over Time")
ax2.set_ylim(0, 105)
plt.tight_layout()
plt.show()
print(f"\nTotal 5-year investment: ${yearly['cumulative_spend'].iloc[-1]:,.0f}")
print(f"Feeders upgraded: {yearly['feeders_upgraded'].sum()}")
print(f"Final risk reduction: {yearly['cumulative_risk_reduction'].iloc[-1]:.1f}%")
Total 5-year investment: $3,612,500
Feeders upgraded: 8
Final risk reduction: 91.4%
The Monte Carlo scenario generation uses random sampling, so fix the seed for reproducible results. Since this guide uses optimization (not a learned model), persistence means saving the optimal solution itself.
np.random.seed(42)
candidates_df.to_csv("optimal_upgrades.csv", index=False)
Feature engineering note: The 4 upgrade types (reconductor, transformer, regulator, battery) represent the standard capital investment options available to distribution planners. Each addresses a different constraint: reconductoring increases thermal capacity for overloaded line segments, transformer upgrades raise substation headroom, voltage regulators correct voltage profile issues, and batteries provide peak shaving to defer other upgrades. This set covers the full range of traditional wires solutions and modern non-wires alternatives.
- Loaded scenario data and growth projections from the Guide 07 Monte Carlo framework
- Defined a cost catalog of grid upgrade options with realistic utility benchmarks
- Formulated and solved a two-stage stochastic mixed-integer program with
scipy.optimize.milp
- Ranked feeders by expected violation severity to prioritize investment
- Performed cost-benefit analysis with NPV calculations and benefit-cost ratios
- Evaluated demand response and battery storage as non-wires alternatives
- Built a sequenced 5-year investment roadmap with cumulative risk tracking
Ideas to Try Next
- Penalty sensitivity: Re-run the MILP with
PENALTY_PER_MW at $250K and $1M to see how the optimal upgrade plan changes under different VoLL assumptions
- Robust optimization: Instead of optimizing over expected cost, minimize the worst-case cost to hedge against extreme scenarios
- Multi-objective: Add reliability (SAIFI reduction) as a second objective alongside cost minimization
- Dynamic NWA sizing: Use the EV charging profiles from
timeseries/ev_charging.parquet to right-size demand response programs per feeder
- Climate risk overlay: Incorporate extreme weather scenarios to stress-test the investment plan against 100-year storm events
Key Terms Glossary
- Stochastic optimization — mathematical optimization that accounts for uncertainty by optimizing over a distribution of possible scenarios
- Two-stage programming — a framework where first-stage decisions are made before uncertainty resolves, and second-stage recourse actions adapt to the realized scenario
- Linear program (LP) — an optimization problem with a linear objective and linear constraints; solvable efficiently even at large scale
- Benefit-cost ratio (BCR) — the ratio of discounted benefits to costs; projects with BCR > 1.0 create net positive value
- Non-wires alternative (NWA) — a solution that defers or avoids traditional grid infrastructure investment using demand response, storage, or distributed generation
- Net present value (NPV) — the value of future cash flows discounted to today's dollars using a discount rate
- ANSI C84.1 — the American National Standard for voltage ranges at service delivery points (120V ± 5%)
- Reconductoring — replacing existing conductors with higher-capacity cables to increase a feeder's thermal rating
- SAIFI / SAIDI / CAIDI — standard reliability indices measuring interruption frequency, duration, and average restoration time