Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/korp-collectors

src/nordpool.rs

//! **Nord Pool** day-ahead electricity spot prices — the Nord Pool data-portal
//! day-ahead document (`.../api/DayAheadPrices?...`). The document is **nested by
//! period, then keyed by bidding zone**: each `multiAreaEntries[]` element carries
//! a `deliveryStart`/`deliveryEnd` window and an `entryPerArea` MAP whose keys are
//! the bidding zones (`SE1`..`SE4`, `NO1`.., `DK1`, `FI`, …) and whose values are
//! the hourly prices. Those variable per-area keys are why this needs a dedicated
//! flattener rather than the generic REST field-map adapter: [`parse_nordpool_json`]
//! FANS the period × area matrix OUT to one flat row per `(area, period)`.
//!
//! Nord Pool prices are **licensed** (paid / data agreement) — this collector is
//! declared [`GdprClass::Gated`] / [`AuthKind::Licensed`] and is default-OFF for
//! outward redistribution; the parse itself is pure-Rust, offline-testable.
//!
//! Offline contract: [`parse_nordpool_json`] turns a canned day-ahead document
//! into per-`(area, period)` rows (area, delivery_start, delivery_end, price,
//! currency) → Arrow.

use anyhow::Result;
use arrow_array::RecordBatch;
use arrow_schema::{DataType, Schema};
use serde_json::Value;

use super::arrow_util::{batch, f64_col, schema, str_col};
use super::{
    AuthKind, BatchSink, Bounds, Checkpoint, CollectStats, Collector, CollectorCfg, CollectorMeta,
    CollectorPath, Direction, GdprClass, SourceEndpoint, SourceKind, SourceTarget,
};

/// One flattened Nord Pool spot price: a single bidding zone's price for a single
/// delivery period.
#[derive(Debug, Clone, PartialEq)]
pub struct NordPoolPrice {
    /// Bidding zone / delivery area (`"SE3"`, `"NO1"`, `"DK1"`, `"FI"`, …).
    pub area: String,
    /// Delivery-period start (ISO-8601 — the CDC / partition key).
    pub delivery_start: Option<String>,
    /// Delivery-period end (ISO-8601).
    pub delivery_end: Option<String>,
    /// Price for this area in this period (document `currency` per MWh).
    pub price: f64,
    /// Document-level currency (`"EUR"`, `"SEK"`, …).
    pub currency: Option<String>,
}

/// Flatten a Nord Pool day-ahead document → per-`(area, period)` price rows.
///
/// Accepts either the modern data-portal shape (`multiAreaEntries[]` with an
/// `entryPerArea` map) or a legacy `areas[]` shape (each area carrying its own
/// `deliveryStart/deliveryEnd/price`). A period with no numeric prices contributes
/// no rows (the collector turns an all-empty document into a RAGNARÖK `reason`).
pub fn parse_nordpool_json(json: &Value) -> Result<Vec<NordPoolPrice>> {
    let currency = json
        .get("currency")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let mut out = Vec::new();

    // Modern shape: multiAreaEntries[] → { deliveryStart, deliveryEnd, entryPerArea{area:price} }.
    if let Some(entries) = json.get("multiAreaEntries").and_then(|v| v.as_array()) {
        for e in entries {
            let ds = e
                .get("deliveryStart")
                .and_then(|v| v.as_str())
                .map(str::to_string);
            let de = e
                .get("deliveryEnd")
                .and_then(|v| v.as_str())
                .map(str::to_string);
            if let Some(per_area) = e.get("entryPerArea").and_then(|v| v.as_object()) {
                for (area, price_v) in per_area {
                    if let Some(price) = as_price(price_v) {
                        out.push(NordPoolPrice {
                            area: area.clone(),
                            delivery_start: ds.clone(),
                            delivery_end: de.clone(),
                            price,
                            currency: currency.clone(),
                        });
                    }
                }
            }
        }
        return Ok(out);
    }

    // Legacy shape: areas[] → { area/areaCode, deliveryStart, deliveryEnd, price }.
    if let Some(areas) = json.get("areas").and_then(|v| v.as_array()) {
        for a in areas {
            let area = a
                .get("area")
                .or_else(|| a.get("areaCode"))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let ds = a
                .get("deliveryStart")
                .and_then(|v| v.as_str())
                .map(str::to_string);
            let de = a
                .get("deliveryEnd")
                .and_then(|v| v.as_str())
                .map(str::to_string);
            if let Some(price) = a.get("price").and_then(as_price) {
                out.push(NordPoolPrice {
                    area,
                    delivery_start: ds,
                    delivery_end: de,
                    price,
                    currency: currency.clone(),
                });
            }
        }
    }
    Ok(out)
}

/// A price value may arrive as a JSON number or a string (some feeds quote it, and
/// EU locales occasionally use a decimal comma) — coerce both, reject others.
fn as_price(v: &Value) -> Option<f64> {
    v.as_f64().or_else(|| {
        v.as_str()
            .and_then(|s| s.trim().replace(',', ".").parse().ok())
    })
}

/// Nord Pool prices → one Arrow `RecordBatch`.
pub fn prices_batch(prices: &[NordPoolPrice]) -> Result<RecordBatch> {
    let cols = vec![
        str_col(
            &prices
                .iter()
                .map(|p| Some(p.area.clone()))
                .collect::<Vec<_>>(),
        ),
        str_col(
            &prices
                .iter()
                .map(|p| p.delivery_start.clone())
                .collect::<Vec<_>>(),
        ),
        str_col(
            &prices
                .iter()
                .map(|p| p.delivery_end.clone())
                .collect::<Vec<_>>(),
        ),
        f64_col(&prices.iter().map(|p| Some(p.price)).collect::<Vec<_>>()),
        str_col(
            &prices
                .iter()
                .map(|p| p.currency.clone())
                .collect::<Vec<_>>(),
        ),
    ];
    batch(&nordpool_schema(), cols)
}

pub fn nordpool_schema() -> Schema {
    schema(&[
        ("area", DataType::Utf8, false),
        ("delivery_start", DataType::Utf8, true),
        ("delivery_end", DataType::Utf8, true),
        ("price", DataType::Float64, false),
        ("currency", DataType::Utf8, true),
    ])
}

/// The Nord Pool day-ahead spot-price collector.
#[derive(Default)]
pub struct NordPoolCollector;

#[async_trait::async_trait]
impl Collector for NordPoolCollector {
    /// **Nord Pool day-ahead prices are LICENSED.** The public data portal serves a
    /// slice without credentials, but the collector's declared endpoint is the
    /// licensed API, so it declares the licence credential by name: an operator who
    /// has none must see WHY, not a 403 rendered as a parse failure.
    fn requirements(&self) -> crate::requires::Requirements {
        crate::requires::Requirements::open(self.source_target().source_endpoint).with_secrets(
            vec![crate::requires::SecretReq::new(
                "NORDPOOL_API_TOKEN",
                "a Nord Pool data licence token (paid; the collector is default-OFF)",
            )],
        )
    }

    /// **The card that has to say "paid" plainly.** Nord Pool is the one source in this
    /// family where an operator can wait forever for a free key that does not exist, so
    /// [`Registration::Agreement`](crate::human_doc::Registration::Agreement) names the
    /// agreement, the address that starts it, and the fact that credentials arrive by SMS.
    ///
    /// Read off Nord Pool's own developer portal on 2026-09-04 (*Introduction to Nord
    /// Pool Market Data API*, *Authentication and access to Market Data API*, *Rate
    /// limits for Market Data API*, *15-minute MTU in Market Data API*). The two 2026-09-04
    /// MEASUREMENTS below were taken against the live data portal from this machine.
    fn human_doc(&self) -> Option<crate::human_doc::HumanDoc> {
        use crate::human_doc::{Contact, HumanDoc, RateLimit, Registration};
        Some(
            HumanDoc::new(
                "Nord Pool AS — the Nordic/Baltic power exchange, part of the Euronext group",
                "Nord Pool AS: the day-ahead auction results are the exchange's OWN market \
                 data, which is why they are licensed rather than open — nobody upstream can \
                 grant you a re-use right to them",
                "https://www.nordpoolgroup.com/globalassets/services/2026-euronext-group-data-agreement-master-terms-and-nord-pool-product-terms.zip",
                "https://developers.nordpoolgroup.com/reference/marketdata-introduction",
            )
            .with_contact(Contact::Email("pds@nordpoolgroup.com".into()))
            .with_registration(Registration::Agreement {
                how: "a PAID Nord Pool / Euronext data agreement. Email pds@nordpoolgroup.com \
                      with \"Market Data API\" in the subject line to start it; credentials \
                      arrive by SMS and are exchanged for a bearer token at \
                      https://sts.nordpoolgroup.com/connect/token. There is no free tier and no \
                      self-service key — do not wait for one."
                    .into(),
            })
            .with_rate_limit(RateLimit::Documented(
                "~1 request/second per endpoint, issued SEQUENTIALLY; the gateway allows \
                 bursts over a rolling 10-second window of 100 on /api/v2/*, 50 on \
                 /api/v2/Intraday, and 25 on /api/v2/Intraday/OrderBook and \
                 /api/v2/HubToHubCapacities"
                    .into(),
            ))
            .with_quirk(
                "The endpoint this collector declares — `dataportal-api.nordpoolgroup.com` — \
                 is the BACKEND OF THE PUBLIC DATA PORTAL, not the licensed Market Data API \
                 (`data-api.nordpoolgroup.com`) the documentation above describes. Measured \
                 2026-09-04: with no credential at all, \
                 `…/api/DayAheadPrices?date=2026-09-03&market=DayAhead&deliveryArea=SE3&currency=EUR` \
                 answered HTTP 200 with a real `multiAreaEntries` document. It is undocumented \
                 and unsupported; the agreement buys the documented API, not this one.",
            )
            .with_quirk(
                "A bare `…/api/DayAheadPrices` with NO query string answers HTTP 401 (measured \
                 2026-09-04). That 401 is a missing-PARAMETER error, not an auth error — do \
                 not go hunting for a token because of it. Supply `date`, `market`, \
                 `deliveryArea` and `currency` first.",
            )
            .with_quirk(
                "Since the switch to a 15-minute Market Time Unit, Day-Ahead \
                 `deliveryStart`/`deliveryEnd` bound 15-MINUTE intervals — four rows per zone \
                 per hour, not one. Measured 2026-09-04 on SE3: 22:00→22:15. Anything \
                 downstream that assumes 24 rows per zone per day is wrong by 4×. (Nord Pool \
                 states UK / N2EX is not affected.)",
            )
            .with_quirk(
                "The licence is not a web page: it is a ZIP of PDFs (the Euronext group data \
                 agreement master terms plus the Nord Pool product terms), and its filename \
                 carries the year — expect the terms URL above to move when the year rolls.",
            ),
        )
    }

    fn meta(&self) -> CollectorMeta {
        CollectorMeta {
            name: "nordpool".into(),
            source_kind: SourceKind::Rest,
            path: CollectorPath::RustNative,
            direction: Direction::Source,
            formats: vec!["json".into()],
            license: "Nord Pool data license (paid/agreement; redistribution-gated)".into(),
            gdpr: GdprClass::Gated,
            auth: AuthKind::Licensed,
            join_keys: vec!["area".into(), "delivery_start".into()],
            description: "Nord Pool day-ahead electricity spot prices — multiAreaEntries \
                          period×zone matrix flattened to per-(area, period) price rows \
                          (SE1–SE4 / NO / DK / FI bidding zones)."
                .into(),
        }
    }

    fn source_target(&self) -> SourceTarget {
        SourceTarget::warehouse(
            SourceEndpoint::NordPool,
            "https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices",
            "nordpool_prices",
        )
    }

    fn schema(&self, _cfg: &CollectorCfg) -> Result<Schema> {
        Ok(nordpool_schema())
    }

    async fn collect(
        &self,
        cfg: &CollectorCfg,
        _bounds: Bounds,
        sink: &BatchSink,
    ) -> Result<CollectStats> {
        let http = cfg.http(10);
        let auth = cfg.auth.resolve_async().await?;
        let body = http.get_text(&cfg.base_url, auth.as_ref()).await?;
        let json: Value = serde_json::from_str(&body)?;
        let prices = parse_nordpool_json(&json)?;
        if prices.is_empty() {
            return Ok(CollectStats {
                reason: Some(
                    "Nord Pool document carried no per-area prices (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,
        })
    }

    async fn checkpoint(&self) -> Result<Option<Checkpoint>> {
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A Nord Pool `multiAreaEntries` day-ahead document flattens to one row per
    /// `(area, period)`; the document currency rides every row. Offline DATA
    /// contract (no network) + a testmatrix cell.
    #[test]
    fn nordpool_multiarea_document_flattens_to_area_period_rows() {
        let json: Value = serde_json::json!({
            "deliveryDateCET": "2026-07-13",
            "currency": "EUR",
            "multiAreaEntries": [
                {
                    "deliveryStart": "2026-07-13T00:00:00Z",
                    "deliveryEnd":   "2026-07-13T01:00:00Z",
                    "entryPerArea": { "SE1": 42.5, "SE3": 55.1, "SE4": 60.2 }
                },
                {
                    "deliveryStart": "2026-07-13T01:00:00Z",
                    "deliveryEnd":   "2026-07-13T02:00:00Z",
                    "entryPerArea": { "SE1": 40.0, "SE3": 50.0, "SE4": 58.0 }
                }
            ]
        });
        let prices = parse_nordpool_json(&json).expect("parse nordpool");
        assert_eq!(prices.len(), 6, "2 periods × 3 zones = 6 flattened rows");

        // Every row carries the document currency and a delivery window.
        assert!(prices.iter().all(|p| p.currency.as_deref() == Some("EUR")));
        assert!(prices.iter().all(|p| p.delivery_start.is_some()));

        // A specific (area, period) → price lookup.
        let se3_h0 = prices
            .iter()
            .find(|p| {
                p.area == "SE3" && p.delivery_start.as_deref() == Some("2026-07-13T00:00:00Z")
            })
            .expect("SE3 hour 0 present");
        assert!((se3_h0.price - 55.1).abs() < 1e-9, "SE3 hour-0 spot price");

        let rb = prices_batch(&prices).expect("prices batch");
        assert_eq!(rb.num_rows(), 6);
        let schema = rb.schema();
        let cols: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
        for want in [
            "area",
            "delivery_start",
            "delivery_end",
            "price",
            "currency",
        ] {
            assert!(
                cols.contains(&want),
                "price table carries `{want}`: {cols:?}"
            );
        }
        let area = rb
            .column_by_name("area")
            .and_then(|a| a.as_any().downcast_ref::<arrow_array::StringArray>())
            .expect("area is utf8");
        let price = rb
            .column_by_name("price")
            .and_then(|a| a.as_any().downcast_ref::<arrow_array::Float64Array>())
            .expect("price is f64");
        // Row order is period-major, then map iteration order per period.
        assert_eq!(
            area.value(0),
            "SE1",
            "first row is the first area of period 0"
        );
        assert!((price.value(0) - 42.5).abs() < 1e-9, "SE1 hour-0 price");

        crate::functional_status(
            "korp-collectors/nordpool",
            "multiarea_flatten",
            true,
            &format!(
                "{} (area, period) spot-price rows from multiAreaEntries",
                rb.num_rows()
            ),
        );
    }

    /// The legacy `areas[]` shape (each area self-contained) also flattens, and a
    /// string-quoted / decimal-comma price is coerced.
    #[test]
    fn nordpool_legacy_areas_shape_and_price_coercion() {
        let json: Value = serde_json::json!({
            "currency": "SEK",
            "areas": [
                { "area": "SE3", "deliveryStart": "2026-07-13T00:00:00Z",
                  "deliveryEnd": "2026-07-13T01:00:00Z", "price": "612,34" },
                { "areaCode": "SE4", "deliveryStart": "2026-07-13T00:00:00Z",
                  "deliveryEnd": "2026-07-13T01:00:00Z", "price": 700.0 }
            ]
        });
        let prices = parse_nordpool_json(&json).expect("parse legacy");
        assert_eq!(prices.len(), 2, "two legacy area rows");
        let se3 = prices.iter().find(|p| p.area == "SE3").expect("SE3");
        assert!(
            (se3.price - 612.34).abs() < 1e-9,
            "decimal-comma string price coerced"
        );
        let se4 = prices
            .iter()
            .find(|p| p.area == "SE4")
            .expect("SE4 via areaCode");
        assert!((se4.price - 700.0).abs() < 1e-9);
        assert_eq!(se4.currency.as_deref(), Some("SEK"));

        crate::functional_status(
            "korp-collectors/nordpool",
            "legacy_areas_and_coercion",
            true,
            "legacy areas[] shape + string/decimal-comma price coercion",
        );
    }

    /// A document with no numeric prices yields zero rows (the collector maps that
    /// to a RAGNARÖK reason) — the RED-when-broken empty guard.
    #[test]
    fn nordpool_empty_document_yields_no_rows() {
        let json: Value = serde_json::json!({ "currency": "EUR", "multiAreaEntries": [] });
        assert!(parse_nordpool_json(&json).expect("parse").is_empty());
        // A period whose prices are all non-numeric also contributes nothing.
        let json2: Value = serde_json::json!({
            "currency": "EUR",
            "multiAreaEntries": [
                { "deliveryStart": "x", "deliveryEnd": "y", "entryPerArea": { "SE3": null } }
            ]
        });
        assert!(parse_nordpool_json(&json2).expect("parse").is_empty());
    }
}