# Data sources What each external source is, what we found when probing it live, and the assumptions we make. Updated as connectors are built. Dates are when the probe was done. ## Open-Meteo (weather) — probed 2026-09-26 | Use | Endpoint | 15-min? | Notes | |---|---|---|---| | D-1 forecast for D (live) | `api.open-meteo.com/v1/forecast` | yes (`minutely_15`) | Used at 11:30 on D-1. | | "Observed" weather | `historical-forecast-api.open-meteo.com/v1/forecast` | yes | Archived best-available model data, gap-free. | | D-1 forecast (backtest) | `previous-runs-api.open-meteo.com/v1/forecast`, variables `*_previous_day1` | yes | What the models predicted one day earlier. The backtest's honest stand-in for the 11:30 D-1 forecast. | | Reanalysis archive | `archive-api.open-meteo.com/v1/archive` | **no** | ERA5 is hourly only, and returned no `minutely_15` block. Not used. | Variables requested: `shortwave_radiation` (GHI), `direct_normal_irradiance`, `diffuse_radiation`, `temperature_2m`, `wind_speed_10m` (`wind_speed_unit=ms`), `cloud_cover`, with `timezone=UTC` and `elevation=550`. **Time labels.** Radiation values in `minutely_15` are means over the *preceding* 15 minutes (the row stamped 10:15 covers 10:00–10:15). We label intervals by their start, so interval S uses the row stamped S+15 min (`openmeteo.parse`). Temperature and wind are instantaneous values, and we take them from the same row. **Grid.** The API snaps to its model grid (lat 45.125, lon 24.0 for our point). The historical endpoint reports the grid-cell elevation (474 m) instead of the 550 m we request. This has no effect on irradiance. **Resolution caveat.** Over Romania, 15-minute values are partly interpolated from hourly model output, because the native 15-min models (e.g. ICON-D2) don't cover all of Romania. Good enough for a paper park, but not a nowcast. **"Actual production" choice (swappable).** No real park meter exists, so "actual" production is the pvlib model run on "observed" weather, in two steps: - *Provisional (during D, every 15 min):* the latest `forecast` run for the intervals that have already ended. That run was issued hours rather than a day before, so it differs from the D-1 forecast the offers used. This is what the live dashboard shows. - *Final (at D+1..D+3, used for settlement):* the `historical_forecast` data for D. It replaces the provisional values; it is the same source the backtest uses. The forecast/actual difference is therefore a forecast-error proxy, not metered reality. Swapping in a real meter feed means replacing `jobs.final_actuals()` and `jobs.actuals_job()`. ## OPCOM PZU clearing prices — probed 2026-09-26 **Export.** A plain GET, no session, cookies or token: ``` https://www.opcom.ro/rapoarte-pzu-raportPIP-export-csv/DD/MM/YYYY/ro?resolution=15 (lei/MWh) https://www.opcom.ro/rapoarte-pzu-raportPIP-export-csv/DD/MM/YYYY/en?resolution=15 (EUR/MWh) ``` `export-xml` also exists. The results page itself is a Laravel POST form with a CSRF token; its HTML table (`div#rez15min table.border_table`, `1.012,55` number format on `/ro`) is the documented fallback, but the CSV has been reliable so it isn't implemented. **CSV format:** - ASCII, CRLF line endings, comma separator, every field quoted, `.` as decimal point. - A title line, then a Base/Peak/Offpeak summary, then the header `Zona de tranzactionare, Interval, Pret de Inchidere a Pietei [lei/MWh], Volum Tranzactionat [MW], … buy [MW], … sell [MW], Rezolutie`. - Volumes are average MW over the interval, so MWh per 15 minutes = MW / 4. - The parser finds the header row by its `Interval` cell. **Not yet published** comes back as **HTTP 200 with an empty body**, so we check the body, not the status. **Interval numbering.** Interval *i* is the *i*-th quarter-hour after local midnight: 96 on a normal day, **100 on 2025-10-26** (autumn clock change) and **92 on 2026-03-29** (spring). We follow exactly that. **15-minute market since 2025-10-01.** Before that the PZU was hourly (24/23/25 rows); the backtest repeats each hourly price across its four quarter-hours. 2025-10-01 itself has no Rezolutie column. **Lei vs EUR.** The PZU clears in EUR. The lei price is the EUR price times the BNR rate from the business day before the auction. We store both, from `/ro` and `/en`, rather than converting ourselves. **Timing.** The auction closes at 12:00 CET (13:00 local) and results appear at about 13:45 local, every day including weekends. The 13:30 job therefore usually retries once or twice (every 15 minutes until 18:45). ## ENTSO-E Transparency (optional cross-check) `documentType=A44`, `in_Domain=out_Domain=10YRO-TEL------P`, UTC period. It needs `ENTSOE_API_TOKEN` and returns 401 without one. Without a token the cross-check is skipped and the prices event says so. With a token, the EUR prices are stored as source `entsoe` and the largest difference from OPCOM goes into the event. Imbalance prices (A85) through the official API are stubbed in `sources/entsoe.py`. ## Imbalance prices — probed 2026-09-26 **Implemented: Transelectrica DAMAS public report `estimatedImbalancePrices`** ``` GET https://newmarkets.transelectrica.ro/usy-durom-publicreportg01/00121002500000000000000000000100/publicReport/estimatedImbalancePrices?timeInterval.from=&timeInterval.to=&pageInfo.pageSize=10000 ``` - **Access:** token-free JSON with 15-minute ISPs, matched on the UTC `timeInterval.from` (so clock-change days line up). History starts 2024-07-01. - **Latency:** about 15–30 minutes after each interval. - **Fields:** `estimatedPriceNegativeImbalance` is the price for a short party (deficit) and `estimatedPricePositiveImbalance` the price for a long party (surplus), in Lei/MWh. `type` is Single or Dual. - **Gaps:** missing values arrive as the string `"N/A"`. For example, 2026-09-25 ISP 53 is missing both here and on ENTSO-E. - **Estimates only.** These are estimated prices; see settlement.md for why we use them. **Checked and not used:** - ENTSO-E A85 through the official API: needs a token. - ENTSO-E's old CSV export: returns HTTP 400 since the site was rebuilt. - ENTSO-E's new site backend (`POST transparency.entsoe.eu/balancing/imbalance/load`): token-free, the only source of *final* prices (status A02, about 60 days after month end), but undocumented. It is planned for a later re-settlement on final prices. - DAMAS `finalImbalancePrices`: returns HTTP 500. - OPCOM "Dezechilibre PRE" PDFs: stop in September 2023. ## BNR FX rates `www.bnr.ro/nbrfxrates.xml` now redirects to the BNR homepage, so we use the `curs.bnr.ro` host: - `https://curs.bnr.ro/nbrfxrates10days.xml` (live, not cached) - `https://curs.bnr.ro/files/xml/years/nbrfxrates{YYYY}.xml` (backtest; cached for past years) The XML has namespace `https://www.bnr.ro/xsd`, with `Cube@date` and `Rate@currency` (and an optional `@multiplier`). Rates are published at about 13:00 local on working days; a delivery day uses the latest rate on or before it.