Metadata-Version: 2.4
Name: fig4python
Version: 0.1.0
Summary: Generalised Carbon-at-Risk portfolio engine (mean–SD scatter, JSON export).
Author: Tom Bearpark, Brody Robins
License: MIT
License-File: LICENSE
Keywords: carbon,climate,monte-carlo,portfolio,risk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Requires-Dist: matplotlib>=3.7
Requires-Dist: numpy>=1.24
Requires-Dist: pandas>=2.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: scipy>=1.10
Provides-Extra: dev
Requires-Dist: ipykernel; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# fig4python

A small, fast, generalised portfolio engine for the Carbon-at-Risk mean–SD
analysis from Lee et al. (NCC submission), Figure 4c.

Given an arbitrary list of carbon-removal technologies — each with a unit cost,
per-project survival probability, and tonnes-per-project — together with a
within-tech and between-tech correlation structure, `fig4python` enumerates
integer portfolios over a grid and returns the mean delivery (μ), standard
deviation (σ), cost, and 5th-percentile delivery (p5) of every portfolio. It
plots the (σ, μ) cloud (Figure 4c-style) and emits a JSON payload suitable for
a web frontend.

The two-technology DACCS + Forest case from
[`ncc/code/main/figure4/figure4.R`](../../ncc/code/main/figure4/figure4.R) is a
parity-tested special case.

## Status

v0.1 — engine, plot, CLI, YAML config, JSON export, and parity tests all
working. See [`docs/slides.md`](docs/slides.md) for the design walkthrough.

Verified outputs:

| Config                 | Portfolios | Feasible  |  JSON |   PDF |
| ---------------------- | ---------: | --------: | ----: | ----: |
| `fig4c.yaml` (N=2)     |    251,001 |   224,758 | 701 K |  90 K |
| `four_tech.yaml` (N=4) |  7,780,611 | 2,633,437 | 723 K |  62 K |

## Layout

```text
fig4python/
├── README.md
├── docs/slides.md            # design slideshow / spec
├── pyproject.toml            # packaging — pip-installable
├── src/fig4python/
│   ├── __init__.py
│   ├── portfolio.py          # core engine (numpy, vectorised)
│   ├── plot.py               # mean–SD scatter
│   ├── io.py                 # YAML config + JSON output
│   └── cli.py                # `python -m fig4python configs/fig4c.yaml`
├── configs/
│   ├── fig4c.yaml            # reproduces Figure 4c (N=2)
│   └── four_tech.yaml        # illustrative N=4 example
├── tests/
│   ├── test_parity_fig4c.py  # vs frozen R outputs (real cross-language parity)
│   └── test_scenarios.py     # editable scenario sweep + faceted figure
├── scripts/
│   └── gen_r_fixture.sh      # regenerate R parity constants
└── outputs/
```

The `src/` layout means this folder can be lifted out and published to PyPI
later with no restructuring.

## Install

Requires Python ≥ 3.10.

```bash
cd fig4python
python3.10 -m venv .venv
source .venv/bin/activate
pip install -e .              # editable; add `pytest ipykernel` for dev
```

## Quick start — Python API

```python
import numpy as np
from fig4python import (
    Technology, PortfolioProblem,
    enumerate_portfolios, min_cost_feasible, plot_mean_sd,
)

problem = PortfolioProblem(
    techs=(
        Technology("DACCS",  cost=500, survival_prob=0.99, q=500),
        Technology("Forest", cost= 40, survival_prob=0.40, q=500),
    ),
    rho_within=np.array([0.5, 0.2]),
    rho_between=np.array([[1.0, 0.0],
                          [0.0, 1.0]]),
    target=30_000,
    max_n=(500, 500),
)

df = enumerate_portfolios(problem)        # pandas DataFrame, one row per portfolio
opt = min_cost_feasible(df)               # cheapest portfolio with p5 ≥ target
fig = plot_mean_sd(df, problem)           # matplotlib Figure
```

## Quick start — CLI

```bash
python -m fig4python configs/fig4c.yaml     --pdf outputs/fig4c.pdf     --json outputs/fig4c.json
python -m fig4python configs/four_tech.yaml --pdf outputs/four_tech.pdf --json outputs/four_tech.json
```

## Interactive scenario sweep

[`tests/test_scenarios.py`](tests/test_scenarios.py) is a self-contained,
editable file that defines a list of `Scenario` objects, solves them all, and
renders a faceted mean–SD figure. Edit the `SCENARIOS` list to add or change
cases — no other file needs touching.

Three ways to use it:

- **Interactively (VS Code Interactive Window / Jupyter)**: open the file
  and run it as one cell (`Shift+Enter` on the whole file, or split into
  `# %%` cells). The figure is bound to `fig` at the bottom and renders
  inline.
- **As a script**: `python tests/test_scenarios.py` — prints a summary
  table of every scenario's optimum.
- **Under pytest**: `pytest tests/test_scenarios.py` — runs comparative-statics
  sanity checks on the optima (independent < correlated, cross-corr hurts
  diversification, etc).

## Worked example: four technologies

[`configs/four_tech.yaml`](configs/four_tech.yaml) defines a 4-tech portfolio
spanning the cost / permanence spectrum:

| Tech     | $/project | survival p | role                       |
| -------- | --------: | ---------: | -------------------------- |
| DACCS    |       500 |       0.99 | engineered, near-certain   |
| BiocharS |       150 |       0.85 | mid-cost, high permanence  |
| Forest   |        40 |       0.40 | cheap, fire-exposed        |
| Soil     |        20 |       0.25 | cheap, low permanence      |

Within-tech correlations (common shocks across same-type projects):
`[0.50, 0.30, 0.20, 0.15]`.

Between-tech correlation matrix (illustrative, not calibrated — captures
shared climate exposure; DACCS is largely independent, Forest and Soil share
a strong drought link):

```text
            DACCS BiocharS Forest Soil
DACCS     [ 1.00   0.05   0.00  0.00 ]
BiocharS  [ 0.05   1.00   0.10  0.10 ]
Forest    [ 0.00   0.10   1.00  0.30 ]
Soil      [ 0.00   0.10   0.30  1.00 ]
```

Target: 20,000 tCO₂ at 95% confidence. Grid: `max_n = [40, 50, 60, 60]` →
**7.78M portfolios**, of which **2.63M** are feasible.

Result:

```text
Min-cost feasible: n_DACCS=7, n_BiocharS=50, n_Forest=50, n_Soil=0
                   cost=$13,000  mu=34,715  p5=20,034
```

The optimum mixes engineered (DACCS), high-permanence biological (BiocharS),
and cheap fire-exposed (Forest), but skips Soil entirely — its drought
correlation with Forest makes it a poor diversifier once Forest is in the
basket. This is exactly the kind of insight a generalised mean–SD engine
makes visible that the two-tech version cannot.

## Output sizes

For website use, both outputs are tuned to stay small:

- **JSON** is columnar (dict-of-lists), rounded to integer tonnes / dollars,
  feasible-only by default, and downsampled to a hard cap (default 20k
  feasible / 2k infeasible). Pass `include_infeasible=True` to keep a sparse
  background cloud. The 7.8M-portfolio 4-tech run fits in **~720 KB**.
- **PDF** rasterizes the scatter at 150 dpi, so file size is independent of
  the number of points. Both example runs land **under 100 KB**.

## Design goals

- **General N**: arbitrary number of technologies.
- **Full correlation structure**: per-tech within correlation + N×N between-tech matrix.
- **Fast**: single vectorised pass (numpy einsum), no Python loop over portfolios.
- **Clear**: ~200 LOC core, no hidden state, no inheritance.
- **Portable**: pip-installable, standalone — no dependency on the parent repo.
- **Web-ready**: emits a compact JSON payload.

## Relationship to the R code

Formulas mirror
[`ncc/code/0_funcs/portfolio_funcs.R`](../../ncc/code/0_funcs/portfolio_funcs.R)
exactly. Two layers of test guard against drift:

1. **Real R parity** (`test_r_parity` in
   [`tests/test_parity_fig4c.py`](tests/test_parity_fig4c.py)) — frozen
   numerical constants generated by actually running `portfolio_stats()` in
   R, including a non-zero between-tech case. The Python engine must
   reproduce μ, σ, p5, and cost to ~1e-9. Regenerate the constants by
   running [`scripts/gen_r_fixture.sh`](scripts/gen_r_fixture.sh) if the R
   formula ever changes.
2. **Formula pinning** (`test_formula_pinning`) — re-implements the R
   algebra in plain numpy and asserts the engine matches it. Catches
   refactor regressions even when R isn't installed.

## Comparative-statics sanity checks

The scenario sweep verifies the engine moves the right way under standard
shocks:

| Scenario                           | Optimum                                 |    Cost |
| ---------------------------------- | --------------------------------------- | ------: |
| fig4c, independent (ρ=0)           | 0 DACCS + 177 Forest                    |  $7,080 |
| fig4c, correlated (ref)            | 61 DACCS + 45 Forest                    | $32,300 |
| fig4c, correlated + 0.3 cross-tech | 69 DACCS + 0 Forest                     | $34,500 |
| fig4c, target = 50k                | 102 DACCS + 64 Forest                   | $53,560 |
| 3-tech (DACCS+Biochar+Forest)      | 0 D + 60 BiocharS + 61 Forest           | $11,440 |
| 4-tech, zero cross-corr            | 0 D + 42 BiocharS + 42 Forest + 57 Soil |  $9,120 |

Reading the table: dropping within-tech correlation makes diversification
unnecessary (forest-only); adding cross-tech correlation flips the optimum to
all-DACCS because forest no longer hedges; the 4-tech case brings Soil back
when its drought link with Forest is severed. All comparative statics check
out — see [`tests/test_scenarios.py`](tests/test_scenarios.py).
