Metadata-Version: 2.5
Name: ngci-profitability-index
Version: 0.0.2
Summary: Ranks energy-efficiency scenarios by profitability index under a budget, 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
Requires-Dist: numpy
Requires-Dist: numpy-financial
Provides-Extra: dev
Requires-Dist: pylint; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# 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`](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 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:

```python
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:

```python
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+.

```bash
pip install cerc-profitability-index
```

From a clone, for development:

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

## See it run

```bash
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.

```python
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:

```python
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:

```python
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

```bash
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](mailto:hossein.jadidi@mail.concordia.ca)
