|
| 1 | +# `max_simple_payback_years` "maximize size" feature (`src/core/reopt.jl`) |
| 2 | + |
| 3 | +> Dev notes — not yet wired into `docs/make.jl` nav, so this page isn't published to |
| 4 | +> the doc site. Promote it (add to `make.jl`) once the design has settled. |
| 5 | +
|
| 6 | +## Design intent |
| 7 | + |
| 8 | +Among solutions satisfying `simple_payback <= max_simple_payback_years`, push |
| 9 | +toward the largest capacity/size, not just the cost-optimal size — capturing extra |
| 10 | +value from generation/energy even when the incremental capex/opex isn't as |
| 11 | +cost-effective as the smaller optimal size. |
| 12 | + |
| 13 | +This is implemented as a two-stage lexicographic solve in `optimize_reopt!`: |
| 14 | +1. Maximize `MaximizeSizeObjective` subject to the `max_simple_payback_years` constraint. |
| 15 | +2. Lock that maximum (within a small tolerance) and minimize lifecycle cost (`Costs`) |
| 16 | + while holding it, so the specific tech mix chosen for that size is the cheapest one. |
| 17 | + |
| 18 | +## Why size-based (`dvSize`/`dvStoragePower`), not production-based |
| 19 | + |
| 20 | +- Chosen deliberately over incentivizing production (`dvRatedProduction`), after |
| 21 | + weighing pros/cons. |
| 22 | +- Size-based avoids the degenerate case: bigger nameplate capacity with no more |
| 23 | + energy produced (pure added cost, no value). |
| 24 | +- Production-based has the mirror-image risk for dispatchable/fuel-burning techs |
| 25 | + (Generator, CHP): the optimizer can satisfy a production incentive by just |
| 26 | + dispatching more during less cost-effective/uneconomical hours, without growing |
| 27 | + size at all — real fuel/O&M costs per kWh already compete against any artificial |
| 28 | + per-kWh incentive, so it either does nothing (incentive too weak) or distorts |
| 29 | + dispatch (incentive too strong) instead of reliably growing size. |
| 30 | +- For intermittent techs (PV, Wind) size and production are ~proportional anyway, |
| 31 | + so the choice barely matters there; the divergence is specific to dispatchable |
| 32 | + techs, which is why size was generalized to work for `p.techs.maximize_size` |
| 33 | + (all techs except ExistingBoiler/ExistingChiller) plus storage power capacity. |
| 34 | +- A possible future "maximize production" option/toggle was explicitly deferred as |
| 35 | + a separate feature — not implemented, no current plan to add it. |
| 36 | + |
| 37 | +## `dvStorageEnergy` excluded from `MaximizeSizeObjective` |
| 38 | + |
| 39 | +`MaximizeSizeObjective` sums `dvSize[t]` (`maximize_size` techs) plus |
| 40 | +`dvStoragePower[b]` only — `dvStorageEnergy[b]` (kWh) is intentionally excluded. |
| 41 | + |
| 42 | +`add_storage_size_constraints` ties energy to power via duration bounds: |
| 43 | + |
| 44 | +```julia |
| 45 | +dvStorageEnergy[b] <= dvStoragePower[b] * max_duration_hours |
| 46 | +dvStorageEnergy[b] >= dvStoragePower[b] * min_duration_hours |
| 47 | +``` |
| 48 | + |
| 49 | +If kWh were included in the stage-1 objective, stage 1 would push energy to the |
| 50 | +duration ceiling "for free" (more objective units, no cost weighting), consuming |
| 51 | +real capex against the payback budget and crowding out PV/battery-kW growth, even |
| 52 | +when that duration wasn't economically justified. |
| 53 | + |
| 54 | +With kWh excluded, stage 1 only maximizes nameplate power/capacity (consistent |
| 55 | +across PV/Wind/CHP/battery-kW); stage 2 picks battery kWh/duration purely on |
| 56 | +economics, bounded only by the existing min/max duration constraints. This applies |
| 57 | +to all storage types in `p.s.storage.types.all` (elec/hot/cold), not just |
| 58 | +`ElectricStorage`, for consistency, even though the duration-ratio constraint |
| 59 | +itself only exists for `ElectricStorage`. |
| 60 | + |
| 61 | +## Related: NPV>=0 vs simple-payback approach |
| 62 | + |
| 63 | +- Old stale branch `NatLabRockies/REopt.jl@max_size_positive_npv` (604 commits |
| 64 | + behind master) implements a genuinely different, NPV-like mechanism: |
| 65 | + `Costs <= bau_lcc + MaximizeProductionIncentive` (full discounted LCC comparison, |
| 66 | + using `p.s.financial.bau_lcc`) combined with a production-based incentive |
| 67 | + (`ProductionByTech`, coefficient -10). This is the likely ancestor of the |
| 68 | + `MaximizeProductionIncentive` naming in current code. |
| 69 | +- Current codebase uses `bau_year_one_operating_cost` + `max_simple_payback_years` |
| 70 | + (undiscounted single-year payback), which is a different/stricter criterion than |
| 71 | + full lifecycle NPV. |
| 72 | +- NPV>=0 is intended to remain a SEPARATE, not-yet-implemented feature — do not |
| 73 | + conflate with the current payback-years-based maximize-size constraint. |
| 74 | + |
| 75 | +## Known numerical gotcha (already fixed) |
| 76 | + |
| 77 | +`Year1OperatingCost` (JuMP expression) vs externally-computed |
| 78 | +`bau_year_one_operating_cost` (Float64) can differ by ~1e-7 floating point noise; |
| 79 | +amplified by the `max_simple_payback_years` multiplier this can flip a |
| 80 | +truly-feasible constraint to INFEASIBLE. Fixed via a `payback_tol` epsilon added |
| 81 | +to the RHS of the payback constraint (see `reopt.jl` comments around |
| 82 | +`PaybackSavings`/`payback_tol`). |
0 commit comments