Skip to main content

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

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)

Source distribution for ngci-profitability-index 0.0.2
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)

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

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