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 (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:
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:
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:
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:
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:
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+.
pip install cerc-retrofit-bankability
From a clone, for development:
pip install -e .[dev]
See it run
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.
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:
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).
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.
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
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:
- Exhaustion — the three amounts sum to the capital cost and the three shares sum to one.
- NPV = 0 — the reported owner contribution is the root of the net present value function, with the sign correct on either side of it.
- 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.
- 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.
- 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.householdswhen theCitycarries them, and otherwise fall back to floor area divided byaverage_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
Metadata
Release files for ngci-retrofit-bankability 0.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ngci_retrofit_bankability-0.0.2.tar.gz | 50.7 kB | Without provenance | Details for ngci_retrofit_bankability-0.0.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ngci_retrofit_bankability-0.0.2-py3-none-any.whl | Python 3 | none | any | Details for ngci_retrofit_bankability-0.0.2-py3-none-any.whl |
Total release size: 85.7 kB
Release files / ngci_retrofit_bankability-0.0.2.tar.gz
| Download URL | ngci_retrofit_bankability-0.0.2.tar.gz |
|---|---|
| Size | 50.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
91ce8c9ace6773873b3ec0d6b92f800ba97880538ffe0b8e85ecfb0bf0abdb8e
|
|
BLAKE2b-256 checksum How to use checksums |
fab81a60149e147535aba88ceea2ce6bce61598428cf9b1b178a6bddc3d51af9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|
Release files / ngci_retrofit_bankability-0.0.2-py3-none-any.whl
| Download URL | ngci_retrofit_bankability-0.0.2-py3-none-any.whl |
|---|---|
| Size | 35.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d569afa9ec54f31d66534e44c43f7e46146592801770ab12d7676e267cf52357
|
|
BLAKE2b-256 checksum How to use checksums |
4bb9a606c977bfd5e1ac3f8eccff84ee0cddf1597ec4fb8d0177e432d0e4511a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|
Security for ngci-retrofit-bankability 0.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.