Gunnar

nordisk/korp-collectors

src/entsoe_day_ahead.rs

//! **#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"
        );
    }
}