Skip to main content

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:

  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

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)

Source distribution for ngci-retrofit-bankability 0.0.2
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)

Table of built distributions (wheels) for ngci-retrofit-bankability 0.0.2
File Interpreter ABI Platform Actions
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.

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page