Profitability index
Ranks competing energy-efficiency scenarios by how much value each returns per dollar invested, then spends a client's budget on the best of them.
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 a whole city.
The four metrics
For each scenario, against the same baseline:
| metric | meaning |
|---|---|
net_present_value |
What the measure is worth today, in dollars. |
profitability_index |
That worth per dollar invested, plus one. One is break-even. This is what the ranking is ordered on, because it makes measures of unlike size comparable — a small measure that doubles its money beats a large one that barely breaks even. |
internal_rate_of_return |
The return the measure earns, as a percentage. None when the cash flow never changes sign, because a fabricated rate would rank a losing measure against profitable ones. |
payback_period_years |
First year the running total turns non-negative. None when the measure never pays back. |
Input: a baseline and one City per scenario
The saving is a difference between two simulated cases, not an assumed percentage. The caller runs the hub factory pipeline for the building as it stands, then once per retrofit alternative, and pairs each result with what that measure costs:
from profitability_index import (AnalysisConfig, Scenario, assess_scenarios,
rank_by_profitability_index, budget_summary)
scenarios = [
Scenario('Heat pump', retrofit=heat_pump_city, system_cost=50_000,
battery_cost=8_000, battery_life_years=10,
annual_operating_cost=4_000, modular=True),
Scenario('Envelope', retrofit=envelope_city, system_cost=100_000, modular=True),
]
results = assess_scenarios(baseline_city, scenarios, AnalysisConfig())
ranking = rank_by_profitability_index(results, budget=150_000)
for allocation in ranking:
print(f'{allocation.name}: {allocation.status} '
f'({allocation.portion_percent}%), PI {allocation.profitability.profitability_index:.2f}')
print(budget_summary(ranking, budget=150_000))
One building instead of a whole city — pass Building objects in place of City objects. Nothing else changes:
results = assess_scenarios(baseline_city.city_object('tower'),
[Scenario('Heat pump', retrofit=retrofit_city.city_object('tower'),
system_cost=50_000)])
Energy is read from energy_consumption_breakdown, so fuels are handled separately and a measure that removes gas while adding electricity is netted rather than counted as a loss.
Scenarios are alternatives competing for one budget, never a programme to be summed.
Installation
Requires Python 3.12+.
pip install cerc-profitability-index
From a clone, for development:
pip install -e .[dev]
See it run
python examples/scenario_ranking.py # prints the metrics and the ranking
python examples/scenario_ranking.py out # also writes the charts to out/
Runs the six pilot scenarios, prints all four metrics, then the ranking both with and
without the skip_unprofitable guard so the difference is visible. The buildings in the
example are stand-ins carrying the pilot energy figures, so it runs in a second without
EnergyPlus or a GeoJSON.
Assumptions
AnalysisConfig carries every financial lever. It is read-only; derive a variant with replace, which leaves the original untouched — the entry point for a sensitivity run.
config = AnalysisConfig(
inflation=0.02, # escalates savings and operating costs
discount_rate=0.04, # nominal
system_life_years=25, # analysis horizon
fuel_prices={cte.GAS: 0.09, cte.ELECTRICITY: 0.0759}, # $/kWh, keyed by hub fuel constants
capital_in_year_one=True, # see "Two inherited conventions" below
)
pessimistic = config.replace(discount_rate=0.08, inflation=0.03)
A fuel absent from fuel_prices is priced at zero, so an unpriced fuel cannot silently inflate a saving.
Budget ranking
rank_by_profitability_index orders scenarios by index, best first, and takes them in that order while the budget lasts:
- Fits →
Selected, taken whole. - Does not fit and
modular=True→Partially Selected, taking a share of what is left, floored to whole percentage points. - Does not fit and
modular=False→Budget Constraint, dropped entirely. Half a boiler is not half a saving.
It is a greedy pass, not an optimiser. That is deliberate: the result is explainable to a client in one sentence and the ordering is stable when an input moves slightly. A knapsack solution could buy marginally more net present value in some cases.
The pass does not stop at the first scenario it cannot afford — a cheap scenario further down is still bought after an expensive one is skipped. That is intended.
Two inherited conventions
The original pilot spreadsheet made two choices this package reproduces by default, so the rewrite gives identical advice. Both are exposed as switches, and both are worth a decision rather than an inheritance.
1. The up-front capital cost falls in year one, not year zero (capital_in_year_one=True). It is therefore discounted by one year, which makes every measure look slightly better than the usual convention would. Set it to False to pay at year zero undiscounted; every net present value falls by exactly one year of discounting on the investment.
2. A value-destroying measure is bought whenever the budget stretches to it (skip_unprofitable=False). In the pilot data this selects Heat Pump (F) — net present value −$70.84, profitability index −0.22 — because budget remained after a more expensive scenario was skipped. Pass skip_unprofitable=True to refuse negative-value measures and leave the money unspent, which is almost certainly what a client wants:
ranking = rank_by_profitability_index(results, budget=384.80, skip_unprofitable=True)
One fixed bug
Partial selection of a modular scenario previously used round(remaining / cost, 2). Rounding up commits more money than the budget holds: with $30 left against a $70 measure, round(0.4286, 2) = 0.43 spends $30.10. The portion is now floored to 0.42. A test asserts spending never exceeds the budget at several budget levels.
This changes no result in the pilot data, where nothing is partially selected.
Reporting
Three charts and a ranking table, written in any format matplotlib supports — the extension decides:
from profitability_index import (plot_yearly_cash_flows, plot_cumulative_cash_flows,
plot_value_against_return, render_ranking_table, ranking_rows)
plot_yearly_cash_flows(ranking, 'out/yearly.svg')
plot_cumulative_cash_flows(ranking, 'out/cumulative.svg') # payback year marked
plot_value_against_return(results, 'out/bubble.png') # NPV vs IRR, size = investment
render_ranking_table(ranking, 'out/ranking.svg', caption='NGCI recommendation')
ranking_rows(ranking) # the same table as formatted text, for a report or web page
ranking_rows is usable without drawing anything, so the ranking can go into a report, a web page or a log directly.
Method
For each year y of the horizon:
flow_y = (annual_saving - operating_cost) * (1 + inflation)^(y-1)
- battery_cost when y is a multiple of battery_life_years (not year 1)
- investment when y == 1 and capital_in_year_one
net_present_value = sum_y flow_y / (1 + discount_rate)^y
profitability_index = 1 + net_present_value / investment
The internal rate of return is computed on the undiscounted stream, and payback is the first year its running total turns non-negative.
net_present_value_at(rate, scenario, saving, config) is exported so the internal rate of return can be verified directly — it is by definition the rate at which that returns zero, and the test suite pins it.
File structure
├── pyproject.toml
├── profitability_index/
│ ├── __init__.py # public exports
│ ├── analysis_config/ # AnalysisConfig — the financial assumptions
│ ├── scenario/ # Scenario — a measure, its costs, and its retrofit City
│ ├── energy_use/ # fuel consumption and the baseline-minus-retrofit saving
│ ├── profitability/ # Profitability result, and assess_scenario / _scenarios
│ ├── budget_ranking/ # ScenarioAllocation, ranking and budget summary
│ ├── reporting/ # charts and the ranking table
│ └── helpers/
│ └── financial.py # present value, IRR, payback, joules to kWh
├── examples/
│ └── scenario_ranking.py # runnable demonstration, no EnergyPlus needed
└── tests/
├── conftest.py # the six pilot scenarios as mock Cities
├── test_profitability.py
├── test_budget_ranking.py
├── test_reporting.py
└── test_data/
└── test.geojson
Tests
pytest
pylint profitability_index
Both also run on every pull request, on the hub-workflow-latest runner, via .gitea/workflows/pr_tests.yaml.
The suite runs on Mock(spec=City) / Mock(spec=Building) fixtures, so it needs no EnergyPlus. The two that matter most are parity tests: all four metrics for all six pilot scenarios, and the full ranking with every status and portion, are pinned against the values the original utils1.py / profitability_pipeline.py produced from scenario_inputs_full.xlsx. If they fail, the package is giving different investment advice from the tool it replaces.
Beyond those: the index equals value per dollar; the reported rate of return really does zero the net present value; spending never exceeds the budget; a city of four identical buildings saves four times one of them at an unchanged index.
A workflow test that builds a real City from tests/test_data/test.geojson through the full factory pipeline is not yet included — it needs one EnergyPlus run for the baseline and one per scenario. See the TODO in tests/conftest.py.
Scope and limitations
- No tax treatment, no financing or debt, no residual asset value, no measure interaction. Scenarios are assumed independent; two that both replace the same boiler would each claim the full saving.
- Energy prices escalate at general inflation, not at a fuel-specific rate.
- The greedy ranking is not an optimiser; see above.
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-profitability-index 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_profitability_index-0.0.2.tar.gz | 42.3 kB | Without provenance | Details for ngci_profitability_index-0.0.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ngci_profitability_index-0.0.2-py3-none-any.whl | Python 3 | none | any | Details for ngci_profitability_index-0.0.2-py3-none-any.whl |
Total release size: 71.7 kB
Release files / ngci_profitability_index-0.0.2.tar.gz
| Download URL | ngci_profitability_index-0.0.2.tar.gz |
|---|---|
| Size | 42.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
606b7f16021f46f12e4aac9bb9a23b1de967e39529f5f077e3b87456a0ef7129
|
|
BLAKE2b-256 checksum How to use checksums |
c9aea5621ee227b1ae38f3a47d929019c8997c90554b24d9f4b4a6631cdb52c3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|
Release files / ngci_profitability_index-0.0.2-py3-none-any.whl
| Download URL | ngci_profitability_index-0.0.2-py3-none-any.whl |
|---|---|
| Size | 29.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
95fbbc06a81aaed2e7fc5ea0dfec5e8a11b1bb1b5d52afe1615946725f43af62
|
|
BLAKE2b-256 checksum How to use checksums |
f70b4d64c4208ff58b4ef0d43aec1d904a0496c360961075adb93b230e74de69
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|
Security for ngci-profitability-index 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.