Python library for building, validating, and serializing mFRR energy activation market bids for the Nordic TSOs
Project description
nexa-mfrr-nordic-eam
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 -
TechnicalLinkbuilder 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,InclusiveGroupbuilders with group constraints validated atbuild()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_MarketDocumentXML with strict XSD element ordering - Deserialize from CIM XML -
deserialize_reserve_bid_document()parses XML back toBidDocumentModel; 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_MarketDocumentresponses (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_dataframeis planned inpandas.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:
- Register as a BSP with your connecting TSO
- Deploy an ECP endpoint (Docker images from ENTSO-E Docker Hub)
- Configure message paths from ediel.org
- 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1da23c02f8e0689fe36aace4eb6ec4a4a809b89fec628fc613cb4a6dc0936150
|
|
| MD5 |
b10e392c60f03474f1f719e416c6606c
|
|
| BLAKE2b-256 |
bf194410ab85e020913b8fb64021fd6cdf449cf4e77e20a0c7c9af9c60809398
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nexa_mfrr_nordic_eam-0.6.0b1.tar.gz -
Subject digest:
1da23c02f8e0689fe36aace4eb6ec4a4a809b89fec628fc613cb4a6dc0936150 - Sigstore transparency entry: 1179838373
- Sigstore integration time:
-
Permalink:
phasenexa/nexa-mfrr-nordic-eam@d05f4d9fccd466ad0cb303fe5a0a83a13aab280d -
Branch / Tag:
refs/tags/v0.6.0b1 - Owner: https://github.com/phasenexa
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d05f4d9fccd466ad0cb303fe5a0a83a13aab280d -
Trigger Event:
release
-
Statement type:
File details
Details for the file nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl.
File metadata
- Download URL: nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl
- Upload date:
- Size: 47.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1a2878f32f12e4a73fd98c2f8f917d4b24172d4e1e4ccab915e9e6c317ea0bef
|
|
| MD5 |
17e7196fe216e8c9e067b23b661380cc
|
|
| BLAKE2b-256 |
280857394f695a8fe885c30d07062184d79ea2c64ef45402d8987d30dc0ed9c2
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nexa_mfrr_nordic_eam-0.6.0b1-py3-none-any.whl -
Subject digest:
1a2878f32f12e4a73fd98c2f8f917d4b24172d4e1e4ccab915e9e6c317ea0bef - Sigstore transparency entry: 1179838374
- Sigstore integration time:
-
Permalink:
phasenexa/nexa-mfrr-nordic-eam@d05f4d9fccd466ad0cb303fe5a0a83a13aab280d -
Branch / Tag:
refs/tags/v0.6.0b1 - Owner: https://github.com/phasenexa
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d05f4d9fccd466ad0cb303fe5a0a83a13aab280d -
Trigger Event:
release
-
Statement type: