Multi-Country Architecture
Overview
EPF runs forecasts for four countries: Spain (ES), Portugal (PT), France (FR), and Germany (DE). Multi-country support shipped over milestones M1–M5 (2026-04-09/10) and was refined through the Phase 5 / v6.0 / Z3 ablation sprint (2026-04-14 → 17).
This page explains the architectural choices that make the model country-aware, what’s shared across countries, and what’s gated per country.
What’s shared
| Component | Shared? | Notes |
|---|---|---|
Feature builder (src/data/feature_engineering.py) | Yes | Takes a country parameter; internal logic branches on holidays and solar latitude |
Target transform residual_1w | Yes (ES/PT/DE); FR trains with no transform | |
| Base model recipe | Yes | XGBoost depth=12, lr=0.03, q=0.55, pw3, d365 |
| Weather ingestion | Yes | Open-Meteo with country-specific population-weighted stations |
| Commodity features | Yes | TTF gas, Brent, ETS — priced in EUR and globally applicable |
| Conformal calibration | Yes | Per country × horizon |
| Cloud Run Jobs | Yes | Container image predictor:v11.0 is country-agnostic; country is passed via env + joblib path |
| API surface | Yes | Every relevant endpoint accepts a ?country= query param with one of ES, PT, FR, DE |
What’s per-country
| Component | Why it varies | Configuration |
|---|---|---|
| Market data source | ES uses REE/ESIOS + OMIE; PT/FR/DE use ENTSO-E Transparency Platform | src/countries.py registry |
| Resolution | ES has 15-minute native; PT/FR/DE are hourly | approach flag — ES uses hybrid15, others use hourly |
| Timezone | ES/PT use Europe/Madrid/Lisbon; FR uses Europe/Paris; DE uses Europe/Berlin | _COUNTRY_TIMEZONES map in src/countries.py |
| Holidays (Z1) | Country-specific public holidays | _get_country_holidays(country_code) in feature_engineering.py |
| Solar elevation (Z4) | Country-specific latitude | _COUNTRY_LATITUDE map |
| Cross-country prices (Z3) | Only DE benefits; ES/FR/PT regress | EPF_CROSS_PRICE_COUNTRIES env var (production: DE) |
| Target transform | FR trains better without it | EPF_TARGET_TRANSFORM per country |
| Joblib artifacts | Each country × horizon has its own | Naming convention direct_model_xgboost_[COUNTRY]_15min_hybrid_[run_mode]_[date].joblib |
See the cross-price gating decision for why Z3 is gated to DE only.
Data ingestion
ENTSO-E Transparency Platform (PT/FR/DE)
For the three non-Spanish countries, EPF ingests directly from transparency.entsoe.eu via the ENTSOE_API_TOKEN:
- Day-ahead prices — A44 document type, hourly resolution
- Actual generation by type — A73 (per PSR type: B16 solar, B19 wind onshore, B18 wind offshore, B01 biomass, B14 nuclear, …)
- Generation forecast (Z2 feature) — A71 for wind+solar, used as a forward-looking model input
- Total load — A65
Five pipeline bug fixes were needed during the initial M1 backfill to handle ENTSO-E’s API quirks — drop trailing Z from timestamps, extract the XML namespace dynamically (version drifts), dedupe stacked TimeSeries revisions, use the DE-LU bidding zone EIC (10Y1001A1001A82H, not the control-area EIC), and chunk multi-month backfills into 30-day windows. These all live in src/data/entsoe_pipeline.py.
REE/ESIOS (ES only)
Spain keeps its richer native feed (16 electricity indicators, 15-minute resolution, real-time dispatch data) alongside the ENTSO-E feed. ES uses REE/ESIOS as the primary source and ENTSO-E as a redundancy check.
Per-country performance (145-day backtest, 2025-11-01 → 2026-03-25)
| Country | DA MAE | Strategic MAE | Winning configuration |
|---|---|---|---|
| ES | 13.99 | 17.35 | v12.0 ablation (no Z3) DA; v11.0 ST |
| PT | 21.94 | 24.67 | v6.0 ablation DA; v6.0 with Z3 ST |
| FR | 24.52 | 28.47 | v6.0 ablation DA; v5.0 ST |
| DE | 27.64 | 35.99 | v6.0 with Z3 (only country where cross-prices help) |
Important context on these numbers: the MAE ceiling is regime-dependent, not structural. On calmer months (e.g. February 2026 for PT) MAE drops to ~14 EUR/MWh. The 145-day window MAE is inflated by the volatile late-2024/early-2025 months that all countries saw.
See the v11.0 post-LSTM correction changelog for the sprint detail.
Country registry
The single source of truth for country metadata lives in src/countries.py. It contains:
- Country code (ISO 2-letter)
- Display name, market name (OMIE / EPEX SPOT), timezone
- Current production model tag per horizon
- Feature flags:
ree_native,supports_intraday,supports_news_phase35 - Weather station list
- Latitude for solar elevation
Adding a fifth country is a contained change: extend src/countries.py, create a new ENTSO-E bidding-zone entry, deploy two Cloud Run Jobs + two Cloud Scheduler triggers, one GCS folder. No VM infrastructure changes needed.
What multi-country changed in the API
Every endpoint that touches data or forecasts accepts a country query parameter (default ES). The key new endpoints added in M2 (shipped 2026-04-10):
/api/v1/countries— enumerate supported countries and their display metadata/api/v1/market/price-history?country=ES— historical day-ahead prices across countries/api/v1/market/generation?country=ES— ENTSO-E generation-by-type breakdown
Full API surface is documented on the API overview page.