Metadata-Version: 2.5
Name: ngci-retrofit-bankability
Version: 0.0.2
Summary: Splits the capital cost of a building retrofit between lender, owner and public purse, from a hub City
License-Expression: LGPL-3.0-or-later
License-File: LICENSE.md
Requires-Python: >=3.12
Requires-Dist: cerc-hub
Requires-Dist: matplotlib
Provides-Extra: dev
Requires-Dist: pylint; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# Retrofit bankability

Splits the capital cost of a building retrofit between the three parties that can fund it: a lender, the building owner, and the public purse.

Built on [`hub`](https://ngci.encs.concordia.ca/gitea/CERC/hub) (`cerc-hub` on PyPI). Every public function takes a `City` or a `Building`, so the same code answers the question for one building or for a whole city without restating anything.

## The three outputs

| output | meaning |
|---|---|
| `financeable_share` | What a lender will advance against the retrofit's own cash flow, sized by a debt service coverage ratio. |
| `owner_share` | The largest share the owner can contribute out of equity and still break even — the contribution at which owner **net present value is exactly zero**. A *ceiling* on what the owner can be asked for, not a forecast of what it will pay. |
| `policy_share` | The residual. The share no private party will fund at these terms, so grants, rebates or forgivable loans must carry it. |

The three are mutually exclusive and exhaust the capital cost, so they sum to one. The dollar amounts (`financeable_cost`, `owner_cost`, `policy_cost`) and `policy_cost_per_dwelling` — the unit a programme budget is actually set in — come out alongside them.

## Input: two hub City objects

The saving is a **difference between two simulated cases**, not an assumed percentage. The caller runs the `hub` factory pipeline twice — once for the building as it stands, once for the retrofitted case — and passes both:

```python
from retrofit_bankability import FinanceConfig, assess_city

result = assess_city(baseline_city, retrofit_city, FinanceConfig())

print(f'{result.financeable_share:.1%} financeable')
print(f'{result.owner_share:.1%} owner at NPV = 0')
print(f'{result.policy_share:.1%} policy gap, ${result.policy_cost_per_dwelling:,.0f} per dwelling')
```

Buildings are matched by `name` across the two cities. A building in the retrofit city with no baseline twin is skipped rather than guessed at.

One building instead of a whole city:

```python
from retrofit_bankability import assess_building

split = assess_building(baseline_city.city_object('highrise'),
                        retrofit_city.city_object('highrise'))
```

Per building across a city, when the distribution matters more than the total:

```python
from retrofit_bankability import assess_buildings

for split in assess_buildings(baseline_city, retrofit_city):
  print(split.name, f'{split.policy_share:.1%}', f'${split.policy_cost_per_dwelling:,.0f}/dwelling')
```

`assess_city` finances each building as its own deal and sums the dollars. That is deliberate and not the same as financing the city as one pooled asset: a building whose cash flow over-covers its own cost cannot lend its surplus to a weaker neighbour.

Energy use is read from `building.energy_consumption_breakdown`, so fuels are handled separately and a fuel switch that removes gas while adding electricity is netted rather than counted as a loss. Onsite photovoltaic output never appears in that breakdown, so it is read from `building.onsite_electrical_production` and valued at the electricity price.

## The inverse question: the owner offers a share

`CapitalSplit` reports the *most* the owner can bear. A negotiation runs the other way round — the owner offers a share, and the questions are what that leaves for public money and what the owner earns on it:

```python
from retrofit_bankability import owner_share_scenarios

for scenario in owner_share_scenarios(baseline_city, retrofit_city, shares=[0.0, 0.1, 0.2]):
  print(f'{scenario.owner_share:.0%} from the owner -> '
        f'policy {scenario.policy_share:.1%} (${scenario.policy_cost_per_dwelling:,.0f}/dwelling), '
        f'owner NPV ${scenario.net_present_value:,.0f}')
```

Use `owner_contribution_scenarios` to pass dollar amounts instead of shares. On the Montreal high-rise:

| owner puts in | dollars | owner NPV | owner return | policy gap | $/dwelling |
|---|---|---|---|---|---|
| 0% | 0 | 197,301 | none | 55.7% | 22,268 |
| 5% | 157,997 | 39,304 | 12.5% | 50.7% | 20,268 |
| 10% | 315,993 | −118,692 | 3.7% | 45.7% | 18,268 |
| 20% | 631,986 | −434,685 | none | 35.7% | 14,268 |

Two things the table makes concrete. The policy gap closes **dollar for dollar** with what the owner adds — and past the break-even ceiling every one of those dollars is a loss to the owner, which is what `net_present_value` turning negative means. The lender amount never moves, because a lender sizes its advance against the retrofit's cash flow, not against the owner's generosity.

`internal_rate_of_return` is the nominal rate at which the cash flow the owner keeps exactly repays the contribution, solved by bisection. It is `None` when the contribution is zero, and `None` again once the contribution is so large the money never comes back — reporting a rate there would rank a losing position against a profitable one.

The same table as a figure, in any format matplotlib supports — the extension decides:

```python
from retrofit_bankability import plot_owner_scenarios

plot_owner_scenarios(scenarios, 'out/owner_scenarios.svg')
```

Stacked bars show who pays, labelled with the policy gap per dwelling; the panel beneath tracks owner net present value, so the crossing point where the owner starts losing money is visible against the shrinking gap. `scenario.as_dict()` gives the same numbers for a report or a table.

## Installation

Requires Python 3.12+.

```bash
pip install cerc-retrofit-bankability
```

From a clone, for development:

```bash
pip install -e .[dev]
```

## See it run

```bash
python examples/montreal_highrise.py          # prints the splits and sensitivities
python examples/montreal_highrise.py out      # also writes the owner chart to out/
```

Runs the Montreal pre-1980 high-rise through the three-way split, then the same code over a
city of forty, then a one-at-a-time sensitivity on the policy gap, then the owner-share table
above. The two cities in the example are stand-ins carrying the Layer 1 EnergyPlus figures, so
it runs in a second without EnergyPlus or a GeoJSON.

## Assumptions

Three objects carry everything a decision maker would change. All are optional — each falls back to the Montreal central case — and all are read-only, so a result stays traceable to the assumptions that produced it.

**`FinanceConfig`** — the financial levers.

```python
config = FinanceConfig(
    horizon_years=20,                 # analysis horizon; equipment life, so no residual value
    realisation_factor=1.00,          # haircut on the simulated saving for the performance gap
    operating_cost_change=0.0,        # change in annual O&M, positive eats the saving
    debt_rate=0.05,                   # all-in nominal loan rate
    debt_service_coverage_ratio=1.20, # higher means less debt and a wider policy gap
    loan_tenor_years=20,              # capped at the horizon
    owner_discount_rate_real=0.07,    # owner cost of equity, real
    inflation=0.02127,                # converts the above to nominal, escalates O&M
    rent_premium_fraction=0.03,       # permitted capital-improvement rent increase; 0.0 switches it off
    rent_monthly_per_dwelling=1346.0, # average rent, dollars per dwelling per month
    average_dwelling_area=99.2,       # used to infer dwellings when the City carries no households
    capital_cost_scale=1.00,          # 1.15 tests a 15% cost overrun
    existing_incentive=0.0,           # grants already committed, netted off before the split
    credit_onsite_generation=True,    # value photovoltaic output at the electricity price
)
```

Derive a variant with `replace`, which leaves the original untouched — this is the entry point for a sensitivity sweep:

```python
strict = config.replace(debt_service_coverage_ratio=1.40, realisation_factor=0.70)
```

**`RetrofitCost`** — what the measures cost. Priced per square meter of floor area, per dwelling, or as a lump sum per building, and a package may mix all three. Defaults to the pre-1980 multi-unit residential deep retrofit package (heat pump, ventilation, windows, air sealing).

```python
from retrofit_bankability import RetrofitCost

costs = RetrofitCost(
    costs_per_square_meter={'heat_pump': 200.8, 'ventilation': 80.8,
                            'windows': 73.2, 'air_sealing': 15.0},
    costs_per_building={'photovoltaic': 262_000.0},
)
costs.capital_cost_breakdown(building, dwellings=79)   # per measure, for a cost review
```

**`EnergyPrices`** — delivered price per fuel in the first year and how it escalates, keyed by the `hub` fuel constants. Defaults to the Quebec nominal projection. A fuel with no configured price contributes **nothing** to the saving rather than taking a default, so an unpriced fuel cannot silently understate the policy gap.

```python
import hub.helpers.constants as cte
from retrofit_bankability import EnergyPrices

prices = EnergyPrices(prices={cte.ELECTRICITY: 0.1066, cte.GAS: 0.0803},
                      escalations={cte.ELECTRICITY: 0.0226, cte.GAS: 0.0226})
```

## Method

Pledgeable cash flow in year `y`, for `y = 1..H`:

```
CF_y = rho * S_y - dOM_y + R          R = 12 * rent_premium_fraction * rent * dwellings
```

`S_y` is the baseline energy bill minus the retrofit energy bill, fuel by fuel, priced at year `y`'s nominal price. A negative `S_y` is kept, not floored: a fuel switch in a cold climate really can raise the bill, and that outcome should surface.

**Debt.** The lender accepts `CF_y / DSCR` as annual debt service, floored at zero — a lender advances nothing against a loss-making year rather than clawing money back — and only out to the tenor `N`:

```
M_y             = max(0, CF_y) / DSCR     for y <= N,  else 0
financeable_cost = sum_y  M_y / (1 + r)^y
```

**Owner at NPV = 0.** The owner keeps what the lender leaves, discounted at its own nominal cost of equity `d_nom = (1 + d)(1 + inflation) - 1`. Contributing equity `E`:

```
NPV(E) = -E + sum_y (CF_y - M_y) / (1 + d_nom)^y  =  0
   =>   owner_cost = sum_y (CF_y - M_y) / (1 + d_nom)^y
```

`owner_net_present_value(E, cash_flows, config)` is exported so this can be verified directly rather than trusted; the test suite pins it as the root.

**Policy.** `policy_cost = max(0, capital_cost - financeable_cost - owner_cost)`.

**Rent premium.** `R` widens the debt and owner legs *at once*, so it is never reported as a fourth stream. `rent_premium_contribution` is an increment — the private capacity with `R` minus the private capacity without it — naming how much capacity exists only because the rent increase is permitted. It is a slice of `financeable_cost + owner_cost`, not an addition to it.

Conventions: capital cost is a positive outflow at year zero, savings are positive inflows at end of year, years are one-indexed. Savings are in **nominal** dollars of their own year, so every discount rate is nominal. Equipment life equals the horizon, so residual asset value is zero.

## File structure

```
├── pyproject.toml
├── retrofit_bankability/
│   ├── __init__.py                  # public exports
│   ├── finance_config/              # FinanceConfig — the financial assumptions
│   ├── retrofit_cost/               # RetrofitCost — what the measures cost
│   ├── energy_prices/               # EnergyPrices — delivered price and escalation per fuel
│   ├── energy_savings/              # the baseline-minus-retrofit energy cost stream
│   ├── capital_split/               # CapitalSplit result, and assess_building / _buildings / _city
│   ├── owner_scenarios/             # OwnerScenario result, and the assumed-contribution functions
│   ├── reporting/                   # the owner-share chart
│   └── helpers/
│       └── conversions.py           # joules to kWh, Fisher relation, present value, shares
├── examples/
│   └── montreal_highrise.py         # runnable demonstration, no EnergyPlus needed
└── tests/
    ├── conftest.py                  # mock City and Building fixtures
    ├── test_capital_split.py
    ├── test_owner_scenarios.py
    ├── test_energy_savings.py
    ├── test_retrofit_cost.py
    ├── test_finance_config.py
    └── test_data/
        └── test.geojson
```

## Tests

```bash
pytest
pylint retrofit_bankability
```

Both also run on every pull request, on the `hub-workflow-latest` runner, via `.gitea/workflows/pr_tests.yaml`.

The suite is built on `Mock(spec=City)` / `Mock(spec=Building)` fixtures, so it runs without EnergyPlus. Beyond the normal paths and edge cases it pins the four properties the results depend on:

1. **Exhaustion** — the three amounts sum to the capital cost and the three shares sum to one.
2. **NPV = 0** — the reported owner contribution is the root of the net present value function, with the sign correct on either side of it.
3. **Monotonicity** — a stricter coverage ratio, a deeper savings haircut and a cost overrun each widen the policy gap. Catches a sign error in the stacking that an exhaustion check alone would not.
4. **Aggregation** — the city split reconciles against its per-building splits, and a city of three identical buildings costs three times one of them at an unchanged share.
5. **Consistency between the two directions** — the owner-scenario ceiling equals the split's owner amount, and contributing exactly that ceiling reproduces the published policy gap. If these diverge the package is answering one question two ways.

A workflow test that builds a real `City` from `tests/test_data/test.geojson` through the full factory pipeline, as the hub package template demonstrates, is **not yet included** — it needs a baseline and a retrofit EnergyPlus run, so the fixture is a pair of simulations rather than one. See the TODO in `tests/conftest.py`.

## Scope and limitations

- Not modelled: tax treatment, refinancing, construction-period interest, vacancy or turnover effects on the rent premium, residual asset value, and any stochastic treatment of the performance gap beyond the single factor `rho`. Each would move the owner leg, and the owner leg is the smallest of the three.
- Dwelling counts come from `building.households` when the `City` carries them, and otherwise fall back to floor area divided by `average_dwelling_area`. The fallback is a guess; supply households where accuracy matters.
- The default costs, prices and rents are Montreal multi-unit residential values. Override all three for anywhere else.

## Provenance of the defaults

Capital unit costs from the BTAP solution sets (`canmetenergy_btap_solutions_2024`, p50, 2020 $ × 1.22); photovoltaic at 2,232 CAD/kW. Energy prices from the Canada Energy Regulator reference projection for Quebec. Rents from the CMHC Rental Market Survey, October 2025. Owner discount rate from Treasury Board cost-benefit analysis guidance; coverage ratio from CMHC standard rental.

## Claude

This repo ships with a `CLAUDE.md`, compatible with AI-assisted coding. If you use a different coding agent, include `CLAUDE.md` in its context. The file also documents our coding standards — review it before contributing. Verify all generated code yourself before opening a pull request.

## Licensing

This project is licensed under LGPL3, which is an open-source license.

## Contributors in Historical Order

- Hossein Jadidi - [hossein.jadidi@mail.concordia.ca](mailto:hossein.jadidi@mail.concordia.ca)
