Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ ENV/

# Simulation output
src/provider_simenv/data/output/

# Web frontend (Issue #23)
web/node_modules/
web/dist/
206 changes: 206 additions & 0 deletions src/provider_simenv/export_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""
Export a simulation run's CSV output into the JSON bundle the web view consumes.

Reads the ``Result_Simulator_*.csv`` files a run writes to ``data/output`` and emits
a single ``bundle.json`` matching the frontend ``Bundle`` contract
(``web/src/data/types.ts``): nodes, edges, per-node time-series (``ticks``) and the
environment time-series (``env``).

Each recorded agent list holds many instances; a map node is the aggregate of its
list per step — extensive quantities are summed, prices/utilisation are averaged,
and ``active`` is true if any instance is active. Ports and sea-lanes are
geographically real but produce no recorded rows, so they carry no ticks
(``hasRecordedData: false``), exactly as the frontend expects.

Geography is deliberately NOT emitted: placement is the frontend gazetteer's job
(keyed by the PDL entity ids carried in ``entityIds``). This is the geolocatable
projection of the model graph — sea crossings are drawn port-to-port rather than
routed through the sea-lane agents, which have no single map location.

Usage:
python -m provider_simenv.export_bundle [--scenario 1] [--input DIR] [--output FILE]
"""
from __future__ import annotations

import argparse
import json
import logging
import os
from datetime import datetime, timezone

import pandas as pd

from .tick_writer import AGENT_TABLES

logger = logging.getLogger(__name__)

# Column aggregation rules for collapsing a list's instances into one node/step.
SUM_COLS = {
"quantity_available", "bra_volume", "arg_volume", "usa_volume",
"feed_received", "livestock_output",
}
MEAN_COLS = {"unit_price", "storage_utilization"}
BOOL_ANY_COLS = {"active"}

# The geolocatable node overlay: display name, agent role, and the PDL entity ids
# the gazetteer places by. Recorded nodes come from AGENT_TABLES; ports are added
# here (they are real places but the DataCollector records no series for them).
NODE_META: dict[str, dict] = {
"bra_farmers": {"label": "Brazil soy farms", "role": "producer", "entityIds": ["brazil_farms"]},
"arg_farmers": {"label": "Argentina soy farms","role": "producer", "entityIds": ["argentina_farms"]},
"usa_farmers": {"label": "US soy farms", "role": "producer", "entityIds": ["us_farms"]},
"wholesalers": {"label": "Wholesalers", "role": "wholesaler", "entityIds": []},
"feed_traders": {"label": "Feed traders", "role": "feed_trader", "entityIds": []},
"processors": {"label": "EU oil mills", "role": "processor", "entityIds": ["eu_oil_mills"]},
"feed_manufacturers": {"label": "Feed mills", "role": "feed_manufacturer", "entityIds": ["feed_mills"]},
"eu_farmers": {"label": "EU livestock farms", "role": "consumer", "entityIds": ["poultry_farms", "pig_farms", "dairy_farms"]},
}

PORT_META: dict[str, dict] = {
"transport_sa_santos": {"label": "Port of Santos", "role": "sa_santos", "entityIds": ["santos_port"]},
"transport_sa_paranagua": {"label": "Port of Paranaguá", "role": "sa_paranagua", "entityIds": ["paranagua_port"]},
"transport_eu_rtm": {"label": "Port of Rotterdam", "role": "eu_rtm", "entityIds": ["rotterdam_port"]},
"transport_eu_ham": {"label": "Port of Hamburg", "role": "eu_ham", "entityIds": ["hamburg_port"]},
}

# Geolocatable flow overlay (sea crossings collapsed port-to-port). Mirrors the
# frontend fixture so the gazetteer/resolveScene contract is unchanged.
EDGES: list[tuple[str, str, bool]] = [
("bra_farmers", "wholesalers", False),
("arg_farmers", "wholesalers", False),
("usa_farmers", "wholesalers", False),
("wholesalers", "transport_sa_santos", False),
("wholesalers", "transport_sa_paranagua", False),
("transport_sa_santos", "transport_eu_rtm", True),
("transport_sa_paranagua", "transport_eu_ham", True),
("arg_farmers", "transport_eu_rtm", True),
("usa_farmers", "transport_eu_rtm", True),
("transport_eu_rtm", "processors", False),
("transport_eu_ham", "processors", False),
("processors", "feed_manufacturers", False),
("feed_manufacturers", "feed_traders", False),
("feed_traders", "eu_farmers", False),
]

ENV_COLS = [
("soja_price", "sojaPrice"),
("feed_price", "feedPrice"),
("shock_scale", "shockScale"),
("drought_severity", "droughtSeverity"),
("total_soja_supply", "totalSojaSupply"),
("transport_utilisation", "transportUtilisation"),
("current_step", "currentStep"),
]

HONESTY_NOTE = "Approximate geographic positions — not GIS accurate"


def _aggregate(df: pd.DataFrame, props: list[str]) -> dict[int, dict]:
"""Collapse a list's per-instance rows into one value dict per period."""
out: dict[int, dict] = {}
grouped = df.groupby("period")
for period, group in grouped:
values: dict = {}
for prop in props:
if prop not in group.columns:
continue
if prop in SUM_COLS:
values[prop] = round(float(group[prop].sum()), 4)
elif prop in MEAN_COLS:
values[prop] = round(float(group[prop].mean()), 4)
elif prop in BOOL_ANY_COLS:
values[prop] = bool(group[prop].astype(bool).any())
else:
values[prop] = round(float(group[prop].mean()), 4)
out[int(period)] = values
return out


def build_bundle(input_dir: str, scenario: int, pdl: str) -> dict:
nodes: list[dict] = []
ticks: list[dict] = []

# Recorded nodes + their aggregated series.
for table, (node_id, props) in AGENT_TABLES.items():
meta = NODE_META.get(node_id)
if meta is None:
logger.warning("no geolocatable metadata for recorded node %r — skipping", node_id)
continue
nodes.append({
"id": node_id, "label": meta["label"], "role": meta["role"],
"entityIds": meta["entityIds"], "hasRecordedData": True,
})
path = os.path.join(input_dir, f"{table}.csv")
if not os.path.exists(path):
logger.warning("missing CSV for %s: %s", node_id, path)
continue
df = pd.read_csv(path)
df = df[df["id_scenario"] == scenario]
for period, values in _aggregate(df, props).items():
ticks.append({"period": period, "nodeId": node_id, "values": values})

# Ports — real places, no recorded series.
for node_id, meta in PORT_META.items():
nodes.append({
"id": node_id, "label": meta["label"], "role": meta["role"],
"entityIds": meta["entityIds"], "hasRecordedData": False,
})

edges = [
{"id": f"{s}->{t}", "source": s, "target": t, "isSeaCrossing": sea}
for (s, t, sea) in EDGES
]

# Environment series.
env: list[dict] = []
env_path = os.path.join(input_dir, "Result_Simulator_Environment.csv")
edf = pd.read_csv(env_path)
edf = edf[edf["id_scenario"] == scenario].sort_values("period")
for _, row in edf.iterrows():
snapshot = {"period": int(row["period"])}
for csv_col, out_key in ENV_COLS:
snapshot[out_key] = round(float(row[csv_col]), 4)
env.append(snapshot)

return {
"meta": {
"pdl": os.path.basename(pdl) if pdl else "s1-soja.pdl.yaml",
"scenario": f"scenario_{scenario}",
"ticks": len(env),
"generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"honestyNote": HONESTY_NOTE,
},
"nodes": nodes,
"edges": edges,
"ticks": ticks,
"env": env,
}


def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
here = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.abspath(os.path.join(here, "..", ".."))

parser = argparse.ArgumentParser(description="Export a run's CSVs to the web bundle.json")
parser.add_argument("--scenario", type=int, default=1,
help="id_scenario to export (0 = baseline, 1 = PDL shock). Default 1.")
parser.add_argument("--input", type=str, default=os.path.join(here, "data", "output"),
help="Directory holding Result_Simulator_*.csv.")
parser.add_argument("--output", type=str, default=os.path.join(repo_root, "web", "public", "bundle.json"),
help="Path to write bundle.json.")
parser.add_argument("--pdl", type=str, default="s1-soja.pdl.yaml", help="PDL name for metadata.")
args = parser.parse_args()

bundle = build_bundle(args.input, args.scenario, args.pdl)
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(bundle, fh, ensure_ascii=False, separators=(",", ":"))

logger.info("wrote %s — %d nodes, %d edges, %d ticks, %d env steps (scenario %d)",
args.output, len(bundle["nodes"]), len(bundle["edges"]),
len(bundle["ticks"]), len(bundle["env"]), args.scenario)


if __name__ == "__main__":
main()
170 changes: 170 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# provider-simenv — web frontend (Issue #23)

World map / globe view for the PROVIDER simulation environment. Renders the geolocatable
projection of the soy supply chain — producers, ports, EU processing, livestock — and plays
back an exported simulation run on a 3D globe.

This directory is a standalone Vite application. It does **not** need the Python simulation
to run: a pre-exported `public/bundle.json` is committed, so `npm install && npm run dev` is
enough to see the view.

---

## Components

| Component | Required? | Purpose |
|---|---|---|
| **Node.js + npm** | yes | Builds and serves the frontend. |
| **`public/bundle.json`** | yes | The run data the view renders. A committed export is already in the repo. |
| **Python simulation** (`src/provider_simenv`) | only to regenerate data | Produces the CSVs that `export_bundle.py` turns into a new `bundle.json`. |
| **PostgreSQL** | no | Not used by the frontend. It is an output target of the simulation only. |

---

## Prerequisites

- **Node.js 20 LTS or newer** (Vite 5 requires `^18.0.0 || >=20.0.0`; Node 18 is end-of-life)
- **npm 10+** (ships with Node 20)

Check with:

```bash
node -v
npm -v
```

A WebGL-capable browser is required — the globe is rendered with three.js.

---

## Install

From this directory (`web/`):

```bash
npm install
```

`package-lock.json` is committed; use `npm ci` instead if you want an exact, reproducible
install.

---

## Start

```bash
# Development server with hot reload — http://localhost:5173
npm run dev

# Production build into web/dist/
npm run build

# Serve the production build locally
npm run preview
```

Quality gates, both used in review:

```bash
npm run typecheck # tsc --noEmit against tsconfig.app.json
npm run lint # eslint src
```

`npm run build` runs `tsc -b` first, so a type error fails the build.

---

## Where the data comes from

The view never talks to the simulation directly. It reads a single JSON bundle through the
`DataSource` seam:

```
simulation run (Melodie)
└─ src/provider_simenv/data/output/Result_Simulator_*.csv
└─ python -m provider_simenv.export_bundle
└─ web/public/bundle.json
└─ staticJsonSource → DataSource → views
```

- `src/data/source.ts` — the `DataSource` interface. Every view depends on this and never on
a concrete source.
- `src/data/staticJsonSource.ts` — fetches `/bundle.json` and validates it structurally
(`parseBundle`) before any view sees it.
- `src/data/fixtureSource.ts` — hand-written bundle, kept for reference and offline work.
- `src/main.tsx` — the composition root and the **only** place a concrete source is chosen.
Swapping sources touches no view file.

Geography is deliberately *not* in the bundle. Coordinates live in the frontend gazetteer
(`src/data/gazetteer.ts`), keyed by PDL entity id. Entities the gazetteer does not know are
not rendered and a console warning is logged — a visible gap is preferred over a confident
wrong placement. Positions are approximate, not GIS accurate.

### Regenerating `bundle.json`

Only needed after a new simulation run. From the **repository root**, with the Python
environment installed (`pip install -e '.[dev]'`):

```bash
# 1. Run the simulation (writes Result_Simulator_*.csv to data/output/).
# Run from the package directory — Melodie resolves data/ paths from the cwd.
cd src/provider_simenv
python main.py --pdl scenarios/s1-soja.pdl.yaml
cd ../..

# 2. Export the CSVs to the web bundle
python -m provider_simenv.export_bundle --scenario 1
```

This writes `web/public/bundle.json`. Options:

| Flag | Default | Meaning |
|---|---|---|
| `--scenario` | `1` | `id_scenario` to export. `0` = baseline, `1` = PDL shock. |
| `--input` | `src/provider_simenv/data/output` | Directory holding the `Result_Simulator_*.csv` files. |
| `--output` | `web/public/bundle.json` | Target path. |
| `--pdl` | `s1-soja.pdl.yaml` | PDL name recorded in the bundle metadata. |

On Windows, set `PYTHONIOENCODING=utf-8` before running either step — the scenario summary
prints box-drawing characters that raise `UnicodeEncodeError` on a cp1252 console.

---

## Layout

```
web/
├── index.html
├── package.json
├── vite.config.ts three/globe.gl split into vendor chunks
├── public/
│ ├── bundle.json exported run data (committed)
│ └── textures/ blue-marble, topology, night-sky
└── src/
├── main.tsx composition root — picks the DataSource
├── App.tsx loads the bundle, owns playback state
├── data/ types, DataSource seam, sources, gazetteer
├── design/tokens.ts colours, globe/atmosphere and arc settings
├── globe/ GlobeView + arc geometry helpers
└── playback/ timeline scrubber + per-period intensity
```

`node_modules/` and `dist/` are gitignored.

---

## Troubleshooting

**"Failed to load the simulation bundle"** — `public/bundle.json` is missing or malformed.
Restore it from git or regenerate it (see above).

**Blank globe, no errors** — the browser has no WebGL. Check `chrome://gpu`, or run the dev
server in a normal browser window rather than an embedded IDE preview pane.

**Markers missing from the map** — the gazetteer has no entry for that PDL entity id. Open
the console; each drop is logged as `[gazetteer] not rendered — …`. Add the entity to
`src/data/gazetteer.ts` to place it.

**Large chunk warning on build** — expected and configured for. three.js and globe.gl exceed
Vite's 500 kB default and are split into their own long-cached vendor chunks
(`vite.config.ts`).
Loading