Skip to main content

Python library for building, validating, and serializing mFRR energy activation market bids for the Nordic TSOs

Project description

nexa-mfrr-nordic-eam

CI codecov Python 3.11+

This project is a work in progress. The API, documentation, and feature set are under active development and subject to change. If you want to get involved, receive progress updates, or have feedback, please open an issue or contact the repo admin.

Python library for building, validating, and serializing mFRR energy activation market bids for the Nordic TSOs (Statnett, Fingrid, Energinet, Svenska kraftnat).

Built for the 75% who connect via API and build their own.

Implementation status

Module Status Notes
types.py Done All enums + Pydantic models (BidTimeSeriesModel, BidDocumentModel, etc.)
exceptions.py Done NexaMFRREAMError, InvalidMTUError, NaiveDatetimeError, BidValidationError
config.py Done Global MARI mode, configure(), get_mari_mode()
timing.py Done MTU, GateClosure, gate_closure(), current_mtu(), mtu_range(), evaluate_conditional_availability()
__init__.py Done Public re-exports including Bid, BidDocument, BuiltBidDocument
bids/simple.py Done Bid factory + SimpleBidBuilder with fluent API
bids/validation.py Done Common + TSO-configurable validation rules
bids/complex.py Done ExclusiveGroup, MultipartGroup, InclusiveGroup builders with group-level validation
bids/linked.py Done TechnicalLink builder; conditional link methods on SimpleBidBuilder
xml/namespaces.py Done Namespace URI constants; SchemaVersion enum; version-aware element name mapping
xml/serialize.py Done Pydantic models to CIM XML; version-aware element names and ordering; defaults to IEC v7.4
xml/deserialize.py Done CIM XML to Pydantic models; handles all three namespace URIs (NBM v7.2, IEC v7.2, IEC v7.4)
tso/base.py Done TSOConfig strategy dataclass
tso/statnett.py Done Statnett (NO) configuration
tso/fingrid.py Done Fingrid (FI) configuration; max 2000 bids, supports inclusive bids
tso/energinet.py Done Energinet (DK) configuration; requires_psr_type, local DA model
tso/svk.py Done Svenska kraftnat (SE) configuration
documents/reserve_bid.py Done BidDocument factory + BidDocumentBuilder + BuiltBidDocument
documents/acknowledgement.py Planned ACK/NACK parser for bid submission responses
pandas.py Planned DataFrame to Bid conversion
examples/ Done Jupyter notebooks: Statnett daily bid prep (GS tax); SVK linked bids; Energinet simple + complex; Fingrid bids + deserialization; Fingrid XML round-trip

What this does

This library covers the bid submission side of the mFRR EAM workflow: building bids, validating them against TSO rules, serializing to CIM XML, and parsing the acknowledgement responses.

Implemented today:

  • Build simple bids - Divisible and indivisible bids with full attribute support (volume, price, resource, product type, duration constraints)
  • Technically linked bids - TechnicalLink builder groups bids across MTUs under a shared link ID to prevent double-activation
  • Conditionally linked bids - .conditionally_available(), .conditionally_unavailable(), .link_to() on the bid builder
  • Complex bid groups - ExclusiveGroup, MultipartGroup, InclusiveGroup builders with group constraints validated at build() time
  • TSO configuration - All four TSOs configured: Statnett (NO), Fingrid (FI), Energinet (DK), Svenska kraftnat (SE)
  • Validate before you send - Common and TSO-configurable validation rules, pre-MARI and post-MARI price limits
  • Serialize to CIM XML - Generates compliant ReserveBid_MarketDocument XML with strict XSD element ordering
  • Deserialize from CIM XML - deserialize_reserve_bid_document() parses XML back to BidDocumentModel; accepts all three namespace URIs (NBM v7.2, IEC v7.2, IEC v7.4)
  • Timing helpers - Gate closure calculations, MTU boundaries, DST handling, MARI vs pre-MARI timing

Planned:

  • Parse acknowledgements - Parse Acknowledgement_MarketDocument responses (ACK/NACK with reason codes)
  • Pandas integration - Build bid portfolios from DataFrames

Out of scope for this repo: Activation order handling, activation responses, heartbeat processing, bid availability reports, and allocation result parsing. These are downstream market processes and may be covered by separate repos. See BID_SUBMISSION.md for details on the connection and submission routine.

Installation

pip install nexa-mfrr-nordic-eam

With Pandas support:

pip install nexa-mfrr-nordic-eam[pandas]

Quick start

Submit a simple divisible bid to Statnett

from datetime import datetime, timezone
from nexa_mfrr_eam import (
    Bid, BidDocument, Direction, MarketProductType,
    BiddingZone, TSO, MARIMode,
)

# Create a simple divisible up-regulation bid
bid = (
    Bid.up(volume_mw=50, price_eur=85.50)
    .divisible(min_volume_mw=10)
    .for_mtu("2026-03-21T10:00Z")
    .resource("NOKG90901", coding_scheme="NNO")  # Statnett regulation object
    .product_type(MarketProductType.SCHEDULED_AND_DIRECT)  # A07
    .build()
)

# Wrap in a document targeting Statnett
doc = (
    BidDocument(tso=TSO.STATNETT)
    .sender(party_id="9999909919920", coding_scheme="A10")  # GS1
    .add_bid(bid)
    .build()
)

# Validate against Statnett-specific rules (pre-MARI timing)
errors = doc.validate(mari_mode=MARIMode.PRE_MARI)
if errors:
    for e in errors:
        print(f"  {e}")
else:
    xml_bytes = doc.to_xml()
    # Send xml_bytes via your ECP endpoint to Statnett

Build a multipart bid

Multipart bids are groups of simple bids at different price levels for the same MTU. If the higher-priced component is accepted, all cheaper components must also be accepted.

from nexa_mfrr_eam import Direction, MultipartGroup

group = (
    MultipartGroup(bidding_zone=BiddingZone.NO2)
    .direction(Direction.UP)
    .for_mtu("2026-03-21T10:00Z")
    .resource("NOKG90901", coding_scheme="NNO")
    .add_component(volume_mw=20, price_eur=50.00)
    .add_component(volume_mw=15, price_eur=75.00)
    .add_component(volume_mw=10, price_eur=120.00)
    .build()
)

doc = (
    BidDocument(tso=TSO.STATNETT)
    .sender(party_id="9999909919920", coding_scheme="A10")
    .add_bids(group)
    .build()
)

Build an exclusive group

Only one bid in the group can be activated. Useful when you have alternative resources that cannot run simultaneously.

from nexa_mfrr_eam import ExclusiveGroup

group = (
    ExclusiveGroup(bidding_zone=BiddingZone.NO2)
    .for_mtu("2026-03-21T10:00Z")
    .add_bid(
        Bid.up(volume_mw=30, price_eur=60.00)
        .indivisible()
        .resource("NOKG90901", coding_scheme="NNO")
    )
    .add_bid(
        Bid.up(volume_mw=50, price_eur=80.00)
        .divisible(min_volume_mw=10)
        .resource("NOKG90902", coding_scheme="NNO")
    )
    .product_type(MarketProductType.SCHEDULED_ONLY)
    .build()
)

Build technically linked bids across MTUs

Technical linking ensures a resource is not double-activated when a bid in one MTU is activated via direct activation and is still ramping during the next MTU.

from nexa_mfrr_eam import TechnicalLink

link = (
    TechnicalLink(bidding_zone=BiddingZone.SE3)
    .resource("REG-OBJ-SE-001", coding_scheme="A01")  # EIC
    .add_mtu(
        mtu="2026-03-21T10:00Z",
        direction=Direction.UP,
        volume_mw=25,
        price_eur=90.00,
        product_type=MarketProductType.SCHEDULED_AND_DIRECT,
    )
    .add_mtu(
        mtu="2026-03-21T10:15Z",
        direction=Direction.UP,
        volume_mw=25,
        price_eur=90.00,
        product_type=MarketProductType.SCHEDULED_AND_DIRECT,
    )
    .add_mtu(
        mtu="2026-03-21T10:30Z",
        direction=Direction.UP,
        volume_mw=25,
        price_eur=90.00,
        product_type=MarketProductType.SCHEDULED_AND_DIRECT,
    )
    .build()
)

Build conditional links

Conditional linking adjusts bid availability in QH0 based on activation outcomes in QH-1 or QH-2.

from nexa_mfrr_eam import ConditionalStatus

# "Make my bid in 10:30 unavailable if the linked bid in 10:15 was activated"
bid_qh_minus_1 = (
    Bid.up(volume_mw=30, price_eur=70.00)
    .divisible(min_volume_mw=5)
    .for_mtu("2026-03-21T10:15Z")
    .resource("NOKG90901", coding_scheme="NNO")
    .build()
)

bid_qh_0 = (
    Bid.up(volume_mw=30, price_eur=70.00)
    .divisible(min_volume_mw=5)
    .for_mtu("2026-03-21T10:30Z")
    .resource("NOKG90901", coding_scheme="NNO")
    .conditionally_available()  # A65
    .link_to(bid_qh_minus_1, status=ConditionalStatus.NOT_AVAILABLE_IF_ACTIVATED)  # A55
    .build()
)

National attributes: maximum duration and resting time

from nexa_mfrr_eam import TechnicalLink, Direction

# A hydro unit that can only run for 90 minutes and needs 60 minutes rest
link = (
    TechnicalLink(bidding_zone=BiddingZone.NO2)
    .resource("NOKG-HYDRO-01", coding_scheme="NNO")
    .max_duration(minutes=90)
    .resting_time(minutes=60)
    .add_mtu("2026-03-21T10:00Z", Direction.UP, 40, 55.00)
    .add_mtu("2026-03-21T10:15Z", Direction.UP, 40, 55.00)
    .add_mtu("2026-03-21T10:30Z", Direction.UP, 40, 55.00)
    .add_mtu("2026-03-21T10:45Z", Direction.UP, 40, 55.00)
    .add_mtu("2026-03-21T11:00Z", Direction.UP, 40, 55.00)
    .add_mtu("2026-03-21T11:15Z", Direction.UP, 40, 55.00)
    .build()
)

Build a bid portfolio from a Pandas DataFrame

Not yet implemented. bids_from_dataframe is planned in pandas.py.

import pandas as pd
from nexa_mfrr_eam.pandas import bids_from_dataframe

df = pd.DataFrame({
    "mtu_start":  pd.to_datetime(["2026-03-21T10:00Z", "2026-03-21T10:15Z", "2026-03-21T10:30Z"]),
    "direction":  ["up", "up", "up"],
    "volume_mw":  [50, 45, 55],
    "price_eur":  [72.30, 74.10, 69.50],
    "min_volume":  [10, 10, 10],
    "resource":   ["NOKG90901"] * 3,
})

bids = bids_from_dataframe(
    df,
    bidding_zone=BiddingZone.NO2,
    resource_coding_scheme="NNO",
    product_type=MarketProductType.SCHEDULED_AND_DIRECT,
    technical_link=True,
)

doc = (
    BidDocument(tso=TSO.STATNETT)
    .sender(party_id="9999909919920", coding_scheme="A10")
    .add_bids(bids)
    .build()
)

MARI mode toggle

Product characteristics change when connecting to MARI. Toggle this globally or per-validation:

from nexa_mfrr_eam import MARIMode, configure

# Set globally
configure(mari_mode=MARIMode.POST_MARI)

# Or per-validation
errors = doc.validate(mari_mode=MARIMode.POST_MARI)

# Key differences:
# - BSP GCT moves from QH-45 to QH-25
# - Max price moves from 10,000 to 15,000 EUR/MWh (later 99,999)
# - Timing changes for TSO GCT, AOF run, etc.

Timing and DX helpers

Gate closure calculator

from nexa_mfrr_eam.timing import gate_closure, MARIMode

# When do I need to submit bids for a given MTU?
mtu_start = datetime(2026, 3, 21, 10, 0, tzinfo=timezone.utc)

gc = gate_closure(mtu_start, mari_mode=MARIMode.PRE_MARI)
print(f"BSP GCT (BEGCT): {gc.bsp_gct}")        # 09:15:00 UTC (QH-45)
print(f"TSO GCT: {gc.tso_gct}")                  # 09:45:00 UTC (QH-15)
print(f"Is gate open now? {gc.is_gate_open()}")

gc_mari = gate_closure(mtu_start, mari_mode=MARIMode.POST_MARI)
print(f"BSP GCT (MARI): {gc_mari.bsp_gct}")     # 09:35:00 UTC (QH-25)

MTU boundaries

from nexa_mfrr_eam.timing import current_mtu, mtu_range

now = datetime.now(timezone.utc)
mtu = current_mtu(now)
print(f"Current MTU: {mtu.start} to {mtu.end}")

# Generate MTU start times for a date range
mtus = mtu_range("2026-03-21T00:00Z", "2026-03-22T00:00Z")
print(f"MTUs in day: {len(mtus)}")  # 96

# DST-aware: handles 23-hour and 25-hour days
mtus_short = mtu_range("2026-03-29T00:00Z", "2026-03-30T00:00Z", tz="CET")
print(f"MTUs on DST transition day: {len(mtus_short)}")  # 92

Bid document size checker

The max is 4000 time series per message (2000 for Fingrid) and 100 messages per MTU:

from nexa_mfrr_eam import BidDocument

doc = (
    BidDocument(tso=TSO.STATNETT)
    .sender(party_id="9999909919920", coding_scheme="A10")
    .add_bids(my_large_bid_list)
    .build()
)

print(f"Bids in document: {doc.time_series_count}")
errors = doc.validate(mari_mode=MARIMode.PRE_MARI)
# errors will include a message if the TSO limit is exceeded

Schema notes

The library targets the IEC 62325-451-7 ReserveBid schema. Three schema versions are accepted by Statnett's test environment:

Version Namespace Element style
NBM v7.2 urn:iec62325:ediel:nbm:reservebiddocument:7:2 Short names (quantity_Measure_Unit.name)
IEC v7.2 urn:iec62325.351:tc57wg16:451-7:reservebiddocument:7:2 Short names
IEC v7.4 urn:iec62325.351:tc57wg16:451-7:reservebiddocument:7:4 Long names (quantity_Measurement_Unit.name)

v7.4 adds mktPSRType.psrType natively and is required for inclusive bid validation on Statnett. The library defaults to v7.4 for serialization and accepts all three during deserialization.

The status element is nested: <status><value>A06</value></status>. All party/area/resource IDs carry a required codingScheme attribute.

Denmark uses a schema variant with additional elements (mktPSRType.psrType mandatory, Note optional) not present in the v7.2 XSDs.

TSO-specific feature matrix

Feature Statnett (NO) Fingrid (FI) Energinet (DK) Svenska kraftnat (SE)
Min bid volume 10 MW (5-9 MW exception) 1 MW 1 MW 1 MW
Exclusive groups Yes Yes Yes Yes
Inclusive groups Yes Yes No No
Multipart bids Yes Yes Yes Yes
Technical linking Yes Yes Yes Yes
Conditional linking Yes (incl. Z04 for period shift) Yes (incl. inclusive bids) Yes (no A71/A72) Yes
Max duration Yes No Yes Yes
Resting time Yes No Yes Yes
Period shift Yes No No No
Faster activation Yes No No No
Non-standard (mFRR-D) Yes No No No
Non-standard (other) Yes No No Yes (overbelastning)
Change product type A05 <-> A07 A05 <-> A07 No A05 <-> A07
Cut-off time for msgs 15 min None specified None specified 6 min
Sender coding scheme A01, A10 A01, A10 A01, A10 A01, A10, NSE
Resource coding scheme NNO A01 A01 A01, NSE
Max bids per message 4000 2000 4000 4000
Fallback portal FiftyWeb Vaksi Web BRP Self Service Portal FiftyWeb

ECP/EDX setup

This library generates CIM XML documents. To send them, you need an ECP/EDX endpoint deployed in the Nordic Energy Messaging (NEM) network. See docs/BID_SUBMISSION.md for the full connection and submission routine, including ECP message types, service codes, and MADES transport details.

The short version:

  1. Register as a BSP with your connecting TSO
  2. Deploy an ECP endpoint (Docker images from ENTSO-E Docker Hub)
  3. Configure message paths from ediel.org
  4. Test in NEM-TEST/PREPROD before production
from nexa_mfrr_eam import BidDocument, TSO

doc = BidDocument(tso=TSO.STATNETT).sender(party_id="...", coding_scheme="A10").add_bid(bid).build()
xml_bytes = doc.to_xml()

# Send xml_bytes via your ECP endpoint (FSSF, AMQP, or MADES SOAP)

Bidding zone EIC codes

The library maps these automatically, but for reference:

Zone EIC Code Country
NO1 10YNO-1--------2 Norway
NO2 10YNO-2--------T Norway
NO3 10YNO-3--------J Norway
NO4 10YNO-4--------9 Norway
NO5 10Y1001A1001A48H Norway
SE1 10Y1001A1001A44P Sweden
SE2 10Y1001A1001A45N Sweden
SE3 10Y1001A1001A46L Sweden
SE4 10Y1001A1001A47J Sweden
DK1 10YDK-1--------W Denmark
DK2 10YDK-2--------M Denmark
FI 10YFI-1--------U Finland

Control area EIC codes (document-level domain.mRID): Energinet 10Y1001A1001A796, Fingrid 10YFI-1--------U, Statnett 10YNO-0--------C, SVK 10YSE-1--------K.

Nordic Market Area (bid-level acquiring_Domain.mRID): 10Y1001A1001A91G.

TSO receiver EIC codes (receiver_MarketParticipant.mRID): Statnett 10X1001A1001A38Y, Fingrid 10X1001A1001A264, Energinet 10X1001A1001A248, SVK 10X1001A1001A418.

Project structure

nexa-mfrr-nordic-eam/
  docs/
    BID_SUBMISSION.md          # Connection and bid submission routine
  examples/
    statnett_bid_preparation.ipynb
    svk_linked_bids.ipynb
    energinet_simple_bids.ipynb
    energinet_complex_bids.ipynb
    fingrid_bids_and_deserialization.ipynb
    fingrid_xml_roundtrip.ipynb
    data/
  src/nexa_mfrr_eam/
    __init__.py              # Public API re-exports
    types.py                 # Enums, Pydantic models
    config.py                # MARI mode, TSO configuration
    exceptions.py            # Typed exceptions
    bids/
      __init__.py
      simple.py              # Simple bid builder
      complex.py             # Exclusive, inclusive, multipart builders
      linked.py              # Technical and conditional link builders
      validation.py          # Common + TSO-specific validation
    documents/
      __init__.py
      reserve_bid.py         # ReserveBid_MarketDocument builder + serializer
      acknowledgement.py     # Acknowledgement_MarketDocument parser
    xml/
      __init__.py
      namespaces.py          # Namespace URIs, version-aware element mapping
      serialize.py           # Pydantic models -> CIM XML
      deserialize.py         # CIM XML -> Pydantic models
      schemas/               # Vendored XSD files (v7.2, v7.4)
    tso/
      __init__.py
      base.py                # Base TSO configuration
      energinet.py           # DK-specific rules
      fingrid.py             # FI-specific rules
      statnett.py            # NO-specific rules
      svk.py                 # SE-specific rules
    timing.py                # MTU calc, gate closures, DST
    pandas.py                # DataFrame -> Bid conversion
  tests/
    conftest.py
    fixtures/                # Example XML files
    test_bids.py
    test_complex.py
    test_config.py
    test_documents.py
    test_linked.py
    test_timing.py
    test_tso_fingrid.py
    test_xml_deserialize.py
    test_xml_serialize.py

Contributing

See CONTRIBUTING.md. Issues and PRs welcome.

This project follows trunk-based development with a protected main branch and squash-only merges.

License

MIT

Links

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nexa_mfrr_nordic_eam-0.6.0b1.tar.gz (41.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl (47.8 kB view details)

Uploaded Python 3

File details

Details for the file nexa_mfrr_nordic_eam-0.6.0b1.tar.gz.

File metadata

  • Download URL: nexa_mfrr_nordic_eam-0.6.0b1.tar.gz
  • Upload date:
  • Size: 41.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nexa_mfrr_nordic_eam-0.6.0b1.tar.gz
Algorithm Hash digest
SHA256 1da23c02f8e0689fe36aace4eb6ec4a4a809b89fec628fc613cb4a6dc0936150
MD5 b10e392c60f03474f1f719e416c6606c
BLAKE2b-256 bf194410ab85e020913b8fb64021fd6cdf449cf4e77e20a0c7c9af9c60809398

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexa_mfrr_nordic_eam-0.6.0b1.tar.gz:

Publisher: publish.yml on phasenexa/nexa-mfrr-nordic-eam

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl.

File metadata

File hashes

Hashes for nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 1a2878f32f12e4a73fd98c2f8f917d4b24172d4e1e4ccab915e9e6c317ea0bef
MD5 17e7196fe216e8c9e067b23b661380cc
BLAKE2b-256 280857394f695a8fe885c30d07062184d79ea2c64ef45402d8987d30dc0ed9c2

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl:

Publisher: publish.yml on phasenexa/nexa-mfrr-nordic-eam

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page