//! **#4c ENTSO-E day-ahead PRICES** — `web-api.tp.entsoe.eu/api?documentType=A44`.
//! The energy-MARKET (price) sibling of the [`entsoe`](super::entsoe) load and
//! [`entsoe_generation`](super::entsoe_generation) volume collectors: the
//! `Publication_MarketDocument` returned for `documentType=A44` (Day-ahead prices)
//! carries a `<price.amount>` per `<Point>` — the hourly €/MWh spot price per
//! bidding zone — where the load/generation documents carry `<quantity>`. This is
//! the OPEN (free ENTSO-E token) route to day-ahead prices; the parallel
//! [`nordpool`](super::nordpool) collector is the same sense over the licensed
//! Nord Pool feed. Reuses the shared zone mapping (`zone_of`/`eic_of`), the
//! `HttpCore` client and `arrow_util` — same framework, `price.amount` instead of
//! `quantity` (plus the document `currency`).
//!
//! Offline contract: [`parse_entsoe_day_ahead_xml`] turns a canned
//! `Publication_MarketDocument` into per-`Point` price rows (domain, bidding zone,
//! currency, resolution, period start, position, price) → Arrow, keyed by
//! `(bidding_zone, period_start, position)` — one price per zone × hour.
use anyhow::Result;
use arrow_array::RecordBatch;
use arrow_schema::{DataType, Schema};
use quick_xml::events::Event;
use super::arrow_util::{batch, f64_col, i64_col, schema, str_col};
use super::entsoe::{eic_of, zone_of};
use super::{
AuthKind, BatchSink, Bounds, CollectStats, Collector, CollectorCfg, CollectorMeta,
CollectorPath, Direction, GdprClass, SourceEndpoint, SourceKind, SourceTarget,
};
/// One day-ahead price `Point` — a single bidding zone's spot price for a single
/// position (hour) of a delivery period.
#[derive(Debug, Clone, PartialEq)]
pub struct EntsoeDayAheadPrice {
pub in_domain: Option<String>,
pub bidding_zone: Option<String>,
/// Document currency (`currency_Unit.name`, e.g. `"EUR"`).
pub currency: Option<String>,
pub resolution: Option<String>,
pub period_start: Option<String>,
/// Position within the period (1-based hour index for `PT60M`).
pub position: i64,
/// Day-ahead price for this zone × position (`price.amount`, currency/MWh).
pub price: f64,
}
fn local_name(name: quick_xml::name::QName) -> String {
String::from_utf8_lossy(name.local_name().as_ref()).into_owned()
}
/// Parse an ENTSO-E `Publication_MarketDocument` (day-ahead prices, `A44`) into
/// per-`Point` price rows. Like [`super::entsoe::parse_entsoe_xml`] but reads the
/// `<price.amount>` leaf (a price document has no `<quantity>`) and captures the
/// `<currency_Unit.name>` that precedes each `TimeSeries`' `<Period>`.
pub fn parse_entsoe_day_ahead_xml(xml: &str) -> Result<Vec<EntsoeDayAheadPrice>> {
let mut reader = quick_xml::Reader::from_str(xml);
let mut prices = Vec::new();
let mut cur = String::new();
let mut domain: Option<String> = None;
let mut currency: Option<String> = None;
let mut resolution: Option<String> = None;
let mut period_start: Option<String> = None;
let mut position: Option<i64> = None;
let mut price: Option<f64> = None;
loop {
match reader.read_event()? {
Event::Start(e) => {
cur = local_name(e.name());
match cur.as_str() {
// A new series: reset the per-series domain + currency.
"TimeSeries" => {
domain = None;
currency = None;
}
"Point" => {
position = None;
price = None;
}
_ => {}
}
}
Event::Text(t) => {
let txt = t.unescape()?.trim().to_string();
if txt.is_empty() {
continue;
}
match cur.as_str() {
"in_Domain.mRID" => domain = Some(txt),
"currency_Unit.name" => currency = Some(txt),
"resolution" => resolution = Some(txt),
// The Period's <timeInterval><start> sits closest to the
// Points; overwriting on every <start> leaves that one in effect.
"start" => period_start = Some(txt),
"position" => position = txt.parse::<i64>().ok(),
// Day-ahead price leaf — the load/generation documents carry
// <quantity> here instead.
"price.amount" => price = txt.parse::<f64>().ok(),
_ => {}
}
}
Event::End(e) => {
let name = local_name(e.name());
if name == "Point" {
if let (Some(pos), Some(p)) = (position, price) {
let bidding_zone = domain.as_deref().and_then(zone_of).map(str::to_string);
prices.push(EntsoeDayAheadPrice {
in_domain: domain.clone(),
bidding_zone,
currency: currency.clone(),
resolution: resolution.clone(),
period_start: period_start.clone(),
position: pos,
price: p,
});
}
}
// Leaf element closed — stop capturing text into the parent.
cur.clear();
}
Event::Eof => break,
_ => {}
}
}
Ok(prices)
}
/// ENTSO-E day-ahead prices → one Arrow `RecordBatch`.
pub fn prices_batch(prices: &[EntsoeDayAheadPrice]) -> Result<RecordBatch> {
let cols = vec![
str_col(
&prices
.iter()
.map(|p| p.in_domain.clone())
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.bidding_zone.clone())
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.currency.clone())
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.resolution.clone())
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.period_start.clone())
.collect::<Vec<_>>(),
),
i64_col(&prices.iter().map(|p| Some(p.position)).collect::<Vec<_>>()),
f64_col(&prices.iter().map(|p| Some(p.price)).collect::<Vec<_>>()),
];
batch(&entsoe_day_ahead_schema(), cols)
}
pub fn entsoe_day_ahead_schema() -> Schema {
schema(&[
("in_domain", DataType::Utf8, true),
("bidding_zone", DataType::Utf8, true),
("currency", DataType::Utf8, true),
("resolution", DataType::Utf8, true),
("period_start", DataType::Utf8, true),
("position", DataType::Int64, false),
("price", DataType::Float64, false),
])
}
/// The ENTSO-E day-ahead spot-price collector.
#[derive(Default)]
pub struct EntsoeDayAheadCollector;
#[async_trait::async_trait]
impl Collector for EntsoeDayAheadCollector {
/// **ENTSO-E needs a free security token, and it has a NAME.** Without
/// `ENTSOE_TOKEN` the Transparency Platform answers 401 to a request that was
/// otherwise well formed — a failure the UI can and must predict, so this
/// collector reads `NeedsConfig` rather than offering a ▶ Run that cannot work.
fn requirements(&self) -> crate::requires::Requirements {
crate::requires::Requirements::open(self.source_target().source_endpoint).with_secrets(
vec![crate::requires::SecretReq::new(
"ENTSOE_TOKEN",
"the free ENTSO-E Transparency Platform security token (register at \
transparency.entsoe.eu, then Account Settings ▸ Web Api Security Token)",
)],
)
}
/// **The operator-facing card** — the shared ENTSO-E half (authority, the token
/// email, 400/min per token, the Open Data list) from
/// [`entsoe_human_doc`](super::entsoe::entsoe_human_doc), plus what bites in an A44
/// PRICE document. The A44 quirks are proved by the tests at the bottom of this file.
fn human_doc(&self) -> Option<crate::human_doc::HumanDoc> {
Some(
super::entsoe::entsoe_human_doc()
.with_quirk(
"An A44 document carries `<price.amount>`, NOT `<quantity>`. A load or \
generation document pointed at this collector yields zero rows — reported \
as a RAGNARÖK reason, not a parse error, so it looks like an empty window \
rather than the wiring mistake it is. Check `documentType=A44` first.",
)
.with_quirk(
"`currency` comes from the document's `<currency_Unit.name>` and is reset \
per `<TimeSeries>` — it is NOT always EUR (a zone can publish in its own \
currency), and the price is per MWh in whatever that row says. Never \
aggregate prices across rows without grouping by `currency`.",
)
.with_quirk(
"This is the FREE-token route to day-ahead prices. The `nordpool` collector \
is the same sense over a paid Nord Pool agreement — prefer this one unless \
you already hold that agreement, because there is no free Nord Pool key to \
wait for.",
),
)
}
fn meta(&self) -> CollectorMeta {
CollectorMeta {
name: "entsoe_day_ahead".into(),
source_kind: SourceKind::Rest,
path: CollectorPath::RustNative,
direction: Direction::Source,
formats: vec!["xml".into()],
license: "CC-BY 4.0 (free token, redistributable)".into(),
gdpr: GdprClass::Open,
auth: AuthKind::Token,
join_keys: vec!["elområde".into(), "period_start".into(), "position".into()],
description: "ENTSO-E Transparency Platform — day-ahead electricity SPOT PRICES \
(documentType A44, price.amount per zone × hour), SE1–SE4 via EIC \
domain codes; the open (free-token) energy-market price feed."
.into(),
}
}
fn source_target(&self) -> SourceTarget {
SourceTarget::warehouse(
SourceEndpoint::EntsoeDayAhead,
"https://web-api.tp.entsoe.eu/api",
"entsoe_day_ahead_prices",
)
}
/// **One price per (domain, period, position)** — a day-ahead curve is 24 (or 96)
/// positions inside one period, so the position is part of the identity.
fn business_key(&self) -> crate::history::BusinessKey {
crate::history::BusinessKey::of(
&["in_domain", "period_start", "position"],
"one day-ahead price per (EIC domain, period, position) — a curve is 24 or \
96 positions inside ONE period",
)
}
fn schema(&self, _cfg: &CollectorCfg) -> Result<Schema> {
Ok(entsoe_day_ahead_schema())
}
async fn collect(
&self,
cfg: &CollectorCfg,
_bounds: Bounds,
sink: &BatchSink,
) -> Result<CollectStats> {
let http = cfg.http(6); // ENTSO-E ≤400 req/min
let auth = cfg.auth.resolve_async().await?; // securityToken via QueryKey auth
// Build the request URL from params (documentType=A44/in_Domain/out_Domain/
// periodStart/periodEnd). A bidding-zone shorthand is mapped to its EIC.
let mut query: Vec<(String, String)> = Vec::new();
if let Some(obj) = cfg.params.as_object() {
for (k, v) in obj {
if let Some(s) = v.as_str() {
let val = if k == "in_Domain" || k == "out_Domain" {
eic_of(s)
.map(str::to_string)
.unwrap_or_else(|| s.to_string())
} else {
s.to_string()
};
query.push((k.clone(), val));
}
}
}
let qs = query
.iter()
.map(|(k, v)| format!("{}={}", k, super::http::percent_encode(v)))
.collect::<Vec<_>>()
.join("&");
let url = if qs.is_empty() {
cfg.base_url.clone()
} else {
format!("{}?{}", cfg.base_url, qs)
};
let xml = http.get_text(&url, auth.as_ref()).await?;
let prices = parse_entsoe_day_ahead_xml(&xml)?;
if prices.is_empty() {
return Ok(CollectStats {
reason: Some("ENTSO-E returned no day-ahead price points (window empty?)".into()),
..Default::default()
});
}
let n = prices.len() as u64;
sink.push(prices_batch(&prices)?)?;
Ok(CollectStats {
rows: n,
batches: 1,
table: cfg.table.clone(),
reason: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An A44 `Publication_MarketDocument` flattens to one price row per
/// `(zone, position)`, reading `<price.amount>` (NOT `<quantity>`), carrying
/// the document currency and the bidding zone resolved from the EIC domain.
/// Rows stay in document order (position-ascending) — the RED-when-broken
/// price-shape contract. Offline DATA (no network) + a testmatrix cell.
#[test]
fn a44_document_flattens_to_zone_hour_price_rows_ordered_by_position() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<Publication_MarketDocument xmlns="urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:0">
<TimeSeries>
<mRID>1</mRID>
<in_Domain.mRID codingScheme="A01">10Y1001A1001A46L</in_Domain.mRID>
<out_Domain.mRID codingScheme="A01">10Y1001A1001A46L</out_Domain.mRID>
<currency_Unit.name>EUR</currency_Unit.name>
<price_Measure_Unit.name>MWH</price_Measure_Unit.name>
<Period>
<timeInterval><start>2026-01-01T00:00Z</start><end>2026-01-01T03:00Z</end></timeInterval>
<resolution>PT60M</resolution>
<Point><position>1</position><price.amount>42.5</price.amount></Point>
<Point><position>2</position><price.amount>39.9</price.amount></Point>
<Point><position>3</position><price.amount>55.1</price.amount></Point>
</Period>
</TimeSeries>
</Publication_MarketDocument>"#;
let prices = parse_entsoe_day_ahead_xml(xml).expect("parse A44");
assert_eq!(prices.len(), 3, "three hourly price points");
// Zone resolved from the EIC domain; currency rides every row.
assert!(
prices
.iter()
.all(|p| p.bidding_zone.as_deref() == Some("SE3"))
);
assert!(prices.iter().all(|p| p.currency.as_deref() == Some("EUR")));
// Rows keep document (position-ascending) order.
let positions: Vec<i64> = prices.iter().map(|p| p.position).collect();
assert_eq!(
positions,
vec![1, 2, 3],
"price points ordered by position/hour"
);
assert!(
(prices[2].price - 55.1).abs() < 1e-9,
"SE3 hour-3 spot price"
);
let rb = prices_batch(&prices).expect("prices batch");
assert_eq!(rb.num_rows(), 3);
let schema = rb.schema();
let cols: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
for want in [
"bidding_zone",
"currency",
"period_start",
"position",
"price",
] {
assert!(
cols.contains(&want),
"price table carries `{want}`: {cols:?}"
);
}
let price = rb
.column_by_name("price")
.and_then(|a| a.as_any().downcast_ref::<arrow_array::Float64Array>())
.expect("price is f64");
assert!((price.value(0) - 42.5).abs() < 1e-9, "first hour price");
crate::functional_status(
"korp-collectors/entsoe_day_ahead",
"a44_price_flatten",
true,
&format!(
"{} (zone, hour) day-ahead price rows from A44 price.amount",
rb.num_rows()
),
);
}
/// A document with no `<price.amount>` points (e.g. a load document mis-pointed
/// here) yields zero rows — the collector maps that to a RAGNARÖK reason. The
/// RED-when-broken empty guard (and proof it does NOT pick up `<quantity>`).
#[test]
fn a44_parser_ignores_quantity_and_empty_document_yields_no_rows() {
// A load document (quantity, no price.amount) → no price rows.
let load = r#"<?xml version="1.0"?>
<Publication_MarketDocument>
<TimeSeries><in_Domain.mRID>10Y1001A1001A46L</in_Domain.mRID>
<Period><resolution>PT60M</resolution>
<Point><position>1</position><quantity>1234</quantity></Point>
</Period>
</TimeSeries>
</Publication_MarketDocument>"#;
assert!(
parse_entsoe_day_ahead_xml(load).expect("parse").is_empty(),
"a <quantity> document is not a price document"
);
}
}