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('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR')],
ports=[
Port(
'gas_grid',
imports=[Flow('gas', size=2000, effects_per_flow_hour={'cost': 0.10})],
),
Port(
'workshop',
exports=[Flow('heat', size=demand_peak, fixed_relative_profile=profile)],
),
],
converters=[
Converter.boiler(
'old_boiler',
thermal_efficiency=0.9,
fuel_flow=Flow('gas', size=300),
thermal_flow=Flow('heat', size=old_size),
),
Converter.boiler(
'new_boiler',
thermal_efficiency=0.9,
fuel_flow=Flow('gas', size=300),
thermal_flow=Flow('heat', size=new_boiler_size),
),
],
objective_effects='cost',
**extra,
)
1. The retrofit decision¶
Investment(min_size, max_size, 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(
min_size=80,
max_size=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')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-cjbe_tpv has 2098 rows; 1001 cols; 3387 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 2e+03]
Cost [4e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
157 rows, 20 cols, 225 nonzeros 0s
34 rows, 17 cols, 90 nonzeros 0s
26 rows, 14 cols, 64 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
Presolve reductions: rows 21(-2077); columns 11(-990); nonzeros 53(-3334)
Solving MIP model with:
21 rows
11 cols (3 binary, 0 integer, 0 implied int., 8 continuous, 0 domain fixed)
53 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 501615.111111 Large 0 0 0 0 0.0s
R 0 0 0 0.00% 21615.111111 501615.11111 95.69% 0 0 0 1 0.0s
1 0 1 100.00% 501615.11111 501615.11111 0.00% 2 1 2 6 0.0s
Solving report
Model linopy-problem-cjbe_tpv
Status Optimal
Primal bound 501615.11111
Dual bound 501615.11111
Gap 0% (tolerance: 0.01%)
P-D integral 0.00168516932158
Solution status feasible
501615.11111 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.01
Max sub-MIP depth 0
Nodes 1
Repair LPs 0
LP iterations 6
0 (strong br.)
5 (separation)
0 (heuristics)
New boiler size: 80 kW
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| 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 min_size floor. The actual capacity gap (115 − 100 = 15 kW peak shortfall) is well below it; if min_size 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(
min_size=80,
max_size=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
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-9wr4d8j7 has 2098 rows; 1001 cols; 3387 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 2e+03]
Cost [4e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
157 rows, 20 cols, 225 nonzeros 0s
34 rows, 17 cols, 90 nonzeros 0s
26 rows, 14 cols, 64 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
Presolve reductions: rows 21(-2077); columns 11(-990); nonzeros 53(-3334)
Solving MIP model with:
21 rows
11 cols (3 binary, 0 integer, 0 implied int., 8 continuous, 0 domain fixed)
53 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 626415.111111 Large 0 0 0 0 0.0s
R 0 0 0 0.00% 21615.111111 626415.11111 96.55% 0 0 0 1 0.0s
C 0 0 0 0.00% 40815.111111 591215.111111 93.10% 2 1 0 6 0.0s
L 0 0 0 0.00% 40815.111111 530415.111111 92.31% 2 1 0 6 0.0s
1 0 1 100.00% 530415.111111 530415.111111 0.00% 2 1 0 8 0.0s
Solving report
Model linopy-problem-9wr4d8j7
Status Optimal
Primal bound 530415.111111
Dual bound 530415.111111
Gap 0% (tolerance: 0.01%)
P-D integral 0.00626650119224
Solution status feasible
530415.111111 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.02
Max sub-MIP depth 1
Nodes 1
Repair LPs 0
LP iterations 8
0 (strong br.)
5 (separation)
2 (heuristics)
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| 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(
min_size=80,
max_size=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')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-j2z3vkyi has 2097 rows; 1001 cols; 3381 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 5e+03]
Cost [4e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
155 rows, 20 cols, 213 nonzeros 0s
32 rows, 18 cols, 85 nonzeros 0s
27 rows, 14 cols, 69 nonzeros 0s
27 rows, 14 cols, 69 nonzeros 0s
Presolve reductions: rows 27(-2070); columns 14(-987); nonzeros 69(-3312)
Solving MIP model with:
27 rows
14 cols (4 binary, 0 integer, 0 implied int., 10 continuous, 0 domain fixed)
69 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 512015.111111 Large 0 0 0 0 0.0s
0 0 0 0.00% 32015.111111 512015.111111 93.75% 0 0 2 1 0.0s
C 0 0 0 0.00% 55055.111111 512015.11111 89.25% 9 4 2 9 0.0s
3 0 2 100.00% 512015.11111 512015.11111 0.00% 9 3 6 21 0.0s
Solving report
Model linopy-problem-j2z3vkyi
Status Optimal
Primal bound 512015.11111
Dual bound 512015.11111
Gap 0% (tolerance: 0.01%)
P-D integral 0.0095362224004
Solution status feasible
512015.11111 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.02
Max sub-MIP depth 1
Nodes 3
Repair LPs 0
LP iterations 21
6 (strong br.)
8 (separation)
0 (heuristics)
new boiler size: 80 kW
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| 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(
min_size=80,
max_size=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
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-ik4wca2o has 2098 rows; 1001 cols; 3387 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 2e+03]
Cost [2e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
157 rows, 20 cols, 225 nonzeros 0s
34 rows, 17 cols, 90 nonzeros 0s
26 rows, 14 cols, 64 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
21 rows, 11 cols, 53 nonzeros 0s
Presolve reductions: rows 21(-2077); columns 11(-990); nonzeros 53(-3334)
Solving MIP model with:
21 rows
11 cols (3 binary, 0 integer, 0 implied int., 8 continuous, 0 domain fixed)
53 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 324023.425822 Large 0 0 0 0 0.0s
T 0 0 0 0.00% 324023.425821 324023.425821 0.00% 0 0 0 0 0.0s
1 0 1 100.00% 324023.425821 324023.425821 0.00% 0 0 0 0 0.0s
Solving report
Model linopy-problem-ik4wca2o
Status Optimal
Primal bound 324023.425821
Dual bound 324023.425821
Gap 0% (tolerance: 0.01%)
P-D integral 1.20379216663e-15
Solution status feasible
324023.425821 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.01
Max sub-MIP depth 0
Nodes 1
Repair LPs 0
LP iterations 0
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| 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(
min_size=80,
max_size=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')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-ez33h4l2 has 2098 rows; 1001 cols; 3387 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 2e+03]
Cost [4e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
157 rows, 20 cols, 225 nonzeros 0s
0 rows, 0 cols, 0 nonzeros 0s
Presolve reductions: rows 0(-2098); columns 0(-1001); nonzeros 0(-3387) - Reduced to empty
Presolve: Optimal
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% 530415.111111 530415.111111 0.00% 0 0 0 0 0.0s
Solving report
Model linopy-problem-ez33h4l2
Status Optimal
Primal bound 530415.111111
Dual bound 530415.111111
Gap 0% (tolerance: 0.01%)
P-D integral 0
Solution status feasible
530415.111111 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.00
Max sub-MIP depth 0
Nodes 0
Repair LPs 0
LP iterations 0
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| 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(
min_size=80,
max_size=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}')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
MIP linopy-problem-0odrw2c7 has 2098 rows; 1001 cols; 3387 nonzeros; 10 integer variables (10 binary)
Coefficient ranges:
Matrix [1e-01, 2e+03]
Cost [4e+00, 4e+00]
Bound [1e+00, 2e+02]
RHS [1e+00, 2e+03]
Presolving model
157 rows, 20 cols, 225 nonzeros 0s
36 rows, 19 cols, 98 nonzeros 0s
32 rows, 16 cols, 82 nonzeros 0s
Presolve reductions: rows 32(-2066); columns 16(-985); nonzeros 82(-3305)
Solving MIP model with:
32 rows
16 cols (5 binary, 0 integer, 0 implied int., 11 continuous, 0 domain fixed)
82 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 100.00% -inf 2415.111111 Large 0 0 0 0 0.0s
1 0 1 100.00% 2415.111111 2415.111111 0.00% 0 0 0 0 0.0s
Solving report
Model linopy-problem-0odrw2c7
Status Optimal
Primal bound 2415.11111111
Dual bound 2415.11111111
Gap 0% (tolerance: 0.01%)
P-D integral 0
Solution status feasible
2415.11111111 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.01
Max sub-MIP depth 0
Nodes 1
Repair LPs 0
LP iterations 0
new boiler size: 0 kW | builds: 0
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
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 |
min_size / max_size |
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, min_size, max_size, ...). 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.