8. Investment
Investment Timing: When to retrofit?¶
T7 used Sizing per-period — capacity flexes each year, paying an annualized fee in every period. That's the right tool when each period's commitment is independent: grid fees, leases, annual tariff brackets. Real assets are lumpier: one boiler, one build, one CAPEX bill, one running-cost stream after. That's Investment.
The natural question Investment answers: given an existing fleet, when should we replace it?
Continuing T7's bakery: the workshop runs on a 100 kW boiler installed years ago. It's been comfortably oversized — but production keeps climbing (peaks 75 → 85 → 95 → 105 → 115 kW across 2024 → 2040). At some point the old boiler can't keep up and a second, larger boiler has to come in alongside it.
We model both: the old boiler as a fixed-size converter (already paid for), the new boiler as an Investment. Same fuel, same heat carrier — they share the load. The optimizer picks when to bring the new one online and how big to make it, weighing CAPEX vs the years of recurring O&M that follow.
Five periods at four-year gaps (2024–2040) give the optimizer room to place the build.
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
from fluxopt import Carrier, Converter, Effect, Flow, Investment, Port, optimize
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
periods = [2024, 2028, 2032, 2036, 2040]
# Daily shape — bakery's rhythm doesn't change.
base_pattern = np.array(
[10, 10, 8, 8, 8, 12, 25, 60, 70, 75, 75, 70, 65, 60, 55, 50, 45, 40, 30, 25, 20, 15, 12, 10],
dtype=float,
)
# Production keeps growing — peaks rise from 75 to 115 kW over 16 years.
peaks = [75, 85, 95, 105, 115]
growth = {p: peak / base_pattern.max() for p, peak in zip(periods, peaks, strict=True)}
time_idx = pd.DatetimeIndex(timesteps, name='time')
demand = pd.DataFrame(
{p: base_pattern * g for p, g in growth.items()},
index=time_idx,
).rename_axis(columns='period')
demand_peak = float(demand.values.max()) # 115 kW
profile = demand / demand_peak
old_boiler_size = 100 # kW — existing capacity
print(f'Demand peaks per period: {peaks} kW\nOld boiler: {old_boiler_size} kW')
Demand peaks per period: [75, 85, 95, 105, 115] kW Old boiler: 100 kW
The 100 kW old boiler covers the first three periods (peaks 75, 85, 95). By 2036 demand hits 105 kW — the old boiler can't keep up alone. A new boiler must be online by then. The question is whether to bring it on earlier.
Two converters share the heat carrier — the old one with a fixed size, the new one with an Investment. The optimizer dispatches both each period and decides when to commission the new one.
def build(new_boiler_size, *, old_size=old_boiler_size, **extra):
return optimize(
timesteps=timesteps,
periods=periods,
carriers=[Carrier(id='gas'), Carrier(id='heat')],
effects=[Effect(id='cost', unit='EUR')],
ports=[
Port(
id='gas_grid',
imports=[Flow(carrier='gas', size=2000, effects_per_flow_hour={'cost': 0.10})],
),
Port(
id='workshop',
exports=[Flow(carrier='heat', size=demand_peak, fixed_relative_profile=profile)],
),
],
converters=[
Converter.boiler(
'old_boiler',
thermal_efficiency=0.9,
fuel_flow=Flow(carrier='gas', size=300),
thermal_flow=Flow(carrier='heat', size=old_size),
),
Converter.boiler(
'new_boiler',
thermal_efficiency=0.9,
fuel_flow=Flow(carrier='gas', size=300),
thermal_flow=Flow(carrier='heat', size=new_boiler_size),
),
],
objective='cost',
**extra,
)
1. The retrofit decision¶
Investment(size_min, size_max, mandatory=False, ...) says: "we may commission a new boiler — at most once across the horizon. Pick the period and the size."
CAPEX (effects_per_size_at_build) is paid once in the build period. Recurring O&M (effects_per_size_recurring) is paid every period the new boiler is active.
result = build(
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=False,
effects_per_size_at_build={'cost': 1500}, # EUR/kW, charged once
effects_per_size_recurring={'cost': 30}, # EUR/kW per active period
),
)
new_size = float(result.solution['invest--size'].sel(flow='new_boiler(heat)').values)
print(f'New boiler size: {new_size:.0f} kW')
result.solution['invest--build'].sel(flow='new_boiler(heat)').to_dataframe(name='built')
New boiler size: 80 kW
| flow | built | |
|---|---|---|
| period | ||
| 2024 | new_boiler(heat) | 0.0 |
| 2028 | new_boiler(heat) | 0.0 |
| 2032 | new_boiler(heat) | 0.0 |
| 2036 | new_boiler(heat) | 1.0 |
| 2040 | new_boiler(heat) | 0.0 |
The optimizer waits until 2036 — the latest period the old boiler can carry alone. Why so late? CAPEX is the same regardless of when you build it. Recurring O&M, however, is charged for every period the new boiler is active. Build in 2024 → 5 periods of recurring; build in 2036 → only 2. With the demand constraint setting a hard "must-be-online-by-2036" deadline, push the spend out.
The chosen size — 80 kW — is the size_min floor. The actual capacity gap (115 − 100 = 15 kW peak shortfall) is well below it; if size_min were lower, the optimizer would size only what's needed.
invest--size reports the chosen new capacity. invest--build is the binary build-period indicator.
2. CAPEX rising over time¶
What if material costs are climbing 8 %/period? Now waiting comes with a penalty: every period of delay means a more expensive boiler. Pass a list aligned with periods to effects_per_size_at_build and the optimizer sees the trajectory.
capex_by_period = [1500, 1620, 1750, 1890, 2040] # +8 %/period
result = build(
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=False,
effects_per_size_at_build={'cost': capex_by_period},
effects_per_size_recurring={'cost': 30},
),
)
df = result.solution['invest--build'].sel(flow='new_boiler(heat)').to_dataframe(name='built')
df['CAPEX (EUR/kW)'] = capex_by_period
df
| flow | built | CAPEX (EUR/kW) | |
|---|---|---|---|
| period | |||
| 2024 | new_boiler(heat) | 1.0 | 1500 |
| 2028 | new_boiler(heat) | 0.0 | 1620 |
| 2032 | new_boiler(heat) | 0.0 | 1750 |
| 2036 | new_boiler(heat) | 0.0 | 1890 |
| 2040 | new_boiler(heat) | 0.0 | 2040 |
Build flips to 2024 — locking in the cheapest CAPEX outweighs the extra years of recurring O&M. This is the central trade-off Investment is designed for: delay savings from recurring vs escalating spend from rising CAPEX. Tune the trajectory and the build period slides between the extremes.
effects_fixed_at_build and effects_fixed_recurring are the analogous fixed-cost variants — one-time and recurring lump sums that don't scale with size. Useful for connection fees, license fees, or anything paid per asset rather than per kW.
3. Mandatory builds and fixed costs¶
Two more knobs:
mandatory=True— the asset must be built exactly once; the optimizer can't skip even when the existing fleet covers demand alone.effects_fixed_at_build— a fixed lump sum charged on installation regardless of size (think: grid hookup fee, license, commissioning). Pairs witheffects_fixed_recurringfor fixed annual fees.
Suppose the bakery has committed to replacing the old boiler regardless of strict need (renewable mandate, end-of-life maintenance contract). Set old_size=120 so the old boiler covers all demand alone, force the build with mandatory=True, and add a 5000 EUR grid hookup fee.
forced_build = build(
old_size=120,
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=True,
effects_per_size_at_build={'cost': 1500},
effects_fixed_at_build={'cost': 5000}, # grid hookup, per-asset
effects_per_size_recurring={'cost': 30},
),
)
new_size = float(forced_build.solution['invest--size'].sel(flow='new_boiler(heat)').values)
print(f'new boiler size: {new_size:.0f} kW')
forced_build.solution['invest--build'].sel(flow='new_boiler(heat)').to_dataframe(name='built')
new boiler size: 80 kW
| flow | built | |
|---|---|---|
| period | ||
| 2024 | new_boiler(heat) | 0.0 |
| 2028 | new_boiler(heat) | 0.0 |
| 2032 | new_boiler(heat) | 0.0 |
| 2036 | new_boiler(heat) | 0.0 |
| 2040 | new_boiler(heat) | 1.0 |
Build forced into 2040 — the latest period available. With nothing to gain from the new boiler operationally (the old one covers everything), the optimizer pushes the build as late as possible to minimize recurring O&M years. The 5000 EUR fixed hookup is paid in 2040 alongside the per-size CAPEX.
The four cost fields combine freely: at_build (charged once) vs recurring (charged every active period), times per_size (scales with kW) vs fixed (per asset). For a connection-fee-only investment, set effects_per_size_at_build={} and only effects_fixed_at_build.
4. NPV: discounting flips the build period¶
T7 introduced period_weights= for NPV discounting — making future euros worth less today. Combine that with the rising-CAPEX scenario from §2 and the build-period decision changes character: discounted future CAPEX may end up cheaper than present CAPEX, even with the 8 %/period climb.
5 % discount rate, 4-year period gaps:
r = 0.05
period_offsets = [0, 4, 8, 12, 16] # year offsets of each period from today
npv_weights = [round(sum(1 / (1 + r) ** (y0 + y) for y in range(4)), 3) for y0 in period_offsets]
result_npv = build(
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=False,
effects_per_size_at_build={'cost': capex_by_period}, # rising 8 %/period
effects_per_size_recurring={'cost': 30},
),
period_weights=npv_weights,
)
df = result_npv.solution['invest--build'].sel(flow='new_boiler(heat)').to_dataframe(name='built')
df['CAPEX (EUR/kW)'] = capex_by_period
df['NPV weight'] = npv_weights
df
| flow | built | CAPEX (EUR/kW) | NPV weight | |
|---|---|---|---|---|
| period | ||||
| 2024 | new_boiler(heat) | 0.0 | 1500 | 3.723 |
| 2028 | new_boiler(heat) | 0.0 | 1620 | 3.063 |
| 2032 | new_boiler(heat) | 0.0 | 1750 | 2.520 |
| 2036 | new_boiler(heat) | 1.0 | 1890 | 2.073 |
| 2040 | new_boiler(heat) | 0.0 | 2040 | 1.706 |
Build flips back to 2036 — even though CAPEX has climbed 26 % by then, the discount weight has fallen from 3.72 to 2.07. Discounted CAPEX in 2036 (1890 × 2.07 ≈ 3915) beats discounted CAPEX in 2024 (1500 × 3.72 = 5580). The optimizer now sees the present-value trade-off, and delay wins.
Two competing forces in NPV-Investment problems:
- Discount rate pushes the build later (future spend worth less).
- CAPEX inflation / technology cost trajectory pushes it earlier (don't pay tomorrow's prices when today's are lower).
The build period is whichever side of the crossover the trajectory lands on. For per-effect overrides — say physical effects (CO₂, fuel kWh) keep flat weights while only cost is discounted — set Effect.period_weights= directly (T7 §2).
5. Edge cases¶
Two edges of the retrofit story:
- Old boiler already too small. Drop
old_sizeto 60 kW — peak demand exceeds it from period 0. The new boiler must be online from 2024. - Old boiler covers the whole horizon. Set
old_sizeto 120 kW (above the 115 kW max peak). Withmandatory=False, the optimizer simply skips the build.
forced = build(
old_size=60,
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=False,
effects_per_size_at_build={'cost': 1500},
effects_per_size_recurring={'cost': 30},
),
)
forced.solution['invest--build'].sel(flow='new_boiler(heat)').to_dataframe(name='built')
| flow | built | |
|---|---|---|
| period | ||
| 2024 | new_boiler(heat) | 1.0 |
| 2028 | new_boiler(heat) | 0.0 |
| 2032 | new_boiler(heat) | 0.0 |
| 2036 | new_boiler(heat) | 0.0 |
| 2040 | new_boiler(heat) | 0.0 |
Forced build in 2024 — there's no "wait" option when demand exceeds the old capacity from day one.
And the other extreme:
skipped = build(
old_size=120,
new_boiler_size=Investment(
size_min=80,
size_max=200,
mandatory=False,
effects_per_size_at_build={'cost': 1500},
effects_per_size_recurring={'cost': 30},
),
)
new_size = float(skipped.solution['invest--size'].sel(flow='new_boiler(heat)').values)
total_builds = int(skipped.solution['invest--build'].sel(flow='new_boiler(heat)').sum('period').values)
print(f'new boiler size: {new_size:.0f} kW | builds: {total_builds}')
new boiler size: 0 kW | builds: 0
No build, size 0 — mandatory=True would force a build even when nothing demands it.
Recap¶
| Field | Meaning |
|---|---|
effects_per_size_at_build |
Per-kW CAPEX, charged once in the build period |
effects_fixed_at_build |
Fixed CAPEX, charged once in the build period |
effects_per_size_recurring |
Per-kW recurring, every active period |
effects_fixed_recurring |
Fixed recurring, every active period |
mandatory |
True = build exactly once, False = at most once |
size_min / size_max |
Capacity bounds when built |
prior_size |
Pre-installed capacity for assets that won't be replaced; pins invest--size to this and disallows invest--build. Use a separate fixed-size converter (as we did here) when the old fleet runs alongside a new investment. |
lifetime |
Periods active after build; None = forever |
The retrofit pattern: model existing assets as fixed-size converters (already paid for, no CAPEX), model new assets as Investment(mandatory=False, size_min, size_max, ...). They share the same carrier and load. The Investment decision answers two coupled questions — when to build and how big — by trading off the four-way at-build/recurring × per-size/fixed cost grid against the demand trajectory.
Investment is intentionally scoped to the v1 baseline: single build, simple CAPEX/OPEX, fixed prior_size. The roadmap for an effects extension (vintage-dependent matrices) and a lifecycle redesign (replacements, additive prior, fleet-of-vintages) is tracked in #169, with concrete sub-issues:
- Replacement after retirement. #102 — once
lifetimeexpires there's no rebuild. - Installments and vintage-dependent costs. #96 (build_period axis), with broader scope in #88.
lifetimesemantics. #104 — units need clarification.- Retrofit with
prior_size. Currently pins capacity and bans new builds; the two-converter pattern in this tutorial sidesteps that limitation. Folded into #169.