Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased - 0.8.0

Interval-timestamped prices. `get_day_ahead_prices`, `get_intraday_prices` and their consumers move from an ambiguous `hour` index to explicit UTC interval boundaries, fixing silent misalignment on 15-minute, DST and mixed-resolution days. Breaking change, ships as 0.8.0 (major-in-0.x).

### Changed
- **`hour` / `price_eur_mwh` removed from `get_day_ahead_prices` and `get_intraday_prices`.** Each price point is now `{ interval_start_utc, interval_end_utc, price }` (ISO 8601, UTC `Z`). `hour` was a same-day-only relative index: it could not represent a 15-minute point, collided across DST transitions, and said nothing about which currency or resolution priced it.
- **A date now means the market delivery day, 00:00 to 24:00 Europe/Brussels** (22:00Z or 23:00Z the day before, 23 or 25 hours on clock-change days), not the UTC calendar day. The ENTSO-E request window follows it and points outside the window are dropped, so a DE request for 2026-03-29 returns exactly its 23 hours. Other ENTSO-E tools still use the UTC-day `dayRange`; aligning them is a follow-up.
- **New response fields on both tools:** `currency` (from the document, EUR if absent, throws if a document's series disagree), `unit` (e.g. `EUR/MWh`), `resolution_minutes` (of the returned points), and `coverage` (`expected_intervals`, `returned_intervals`, `missing_interval_starts`, `duplicates_dropped`) so a partial or gappy publication is visible instead of silently short.
- **Mixed-resolution documents (DE/GB 60-minute + 15-minute series in one response) keep the finest resolution where series overlap**, with a `notes` entry counting the dropped coarse points; outside the overlap (a range crossing a 60m to 15m go-live) the coarse points are kept. `resolution_minutes` is the finest resolution present and `expected_intervals` counts that grid.
- **Same-start duplicate points are deduplicated.** Equal values collapse to one point and count in `duplicates_dropped`; differing values keep the first series and count in `conflicts`, naming the dropped series in `notes`. A live DE/FR run after this ships should confirm `conflicts == 0` on ordinary days.
- **`get_price_spread_analysis`** keys its schedule by `interval_start_utc` and selects `cycles * 60 / resolution_minutes` intervals per side, so an "N full cycles" request behaves the same at hourly or 15-minute resolution. `currency` now passes through to the response.
- **`get_intraday_da_spread`** joins day-ahead and intraday on `interval_start_utc`, averaging the finer series up to the coarser grid first, and reports `unmatched_intervals` instead of assuming both sides share the same hours.
- **`estimate_site_revenue`'s capture-price daylight filter** now reads the UTC hour of `interval_start_utc` (06:00-18:00 UTC), documented as an approximation rather than local solar time.

### Verification
- 455 JS tests passing (was 423 at 0.7.0; +32 covering interval extraction, DST spring-forward/fall-back days (end to end through coverage), mixed-resolution overlap handling (including a range crossing a 60m to 15m go-live), duplicate/conflict dedup, coverage gaps, empty documents, and the spread/site-revenue consumers)
- Python test suite green against the updated fake server payloads
- No captured live A44 fixture was available for this change (no ENTSO-E key on this box); the first live run after merge should check `conflicts == 0` for DE and FR and record the result here
- TypeScript build clean

## 0.7.0 - 2026-08-09

ENTSO-E imbalance-data correctness release. All seven findings from issue #21 (external report) addressed; four parser/query defects fixed and verified against the live ENTSO-E API, plus a new authoritative German data source.
Expand Down
3 changes: 3 additions & 0 deletions python/luminus/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ def _frame_rows(self, data_key: str | None = None) -> list[dict[str, Any]]:

if isinstance(value, dict):
list_keys = [key for key, item in value.items() if isinstance(item, list)]
if len(list_keys) > 1:
# Side lists such as `notes: [str]` must not hide the real list-of-dict rows.
list_keys = [key for key in list_keys if value[key] and all(isinstance(i, dict) for i in value[key])]
if len(list_keys) == 1:
list_key = list_keys[0]
rows = value[list_key]
Expand Down
45 changes: 41 additions & 4 deletions python/tests/fake_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,29 @@ def send_tool_result(message_id, payload, *, is_error=False):
if name == "get_day_ahead_prices":
payload = {
"zone": args.get("zone", "DE"),
"currency": "EUR",
"unit": "EUR/MWh",
"resolution_minutes": 60,
"prices": [
{"hour": 0, "price_eur_mwh": 45.1},
{"hour": 1, "price_eur_mwh": 47.2},
{
"interval_start_utc": "2026-04-02T00:00:00.000Z",
"interval_end_utc": "2026-04-02T01:00:00.000Z",
"price": 45.1,
},
{
"interval_start_utc": "2026-04-02T01:00:00.000Z",
"interval_end_utc": "2026-04-02T02:00:00.000Z",
"price": 47.2,
},
],
"stats": {"min": 45.1, "max": 47.2, "mean": 46.15},
"coverage": {
"expected_intervals": 2,
"returned_intervals": 2,
"missing_interval_starts": [],
"duplicates_dropped": 0,
},
"conflicts": 0,
}
elif name == "get_generation_mix":
payload = {
Expand Down Expand Up @@ -643,10 +661,29 @@ def send_tool_result(message_id, payload, *, is_error=False):
elif name == "get_intraday_prices":
payload = {
"zone": args.get("zone", "GB"),
"currency": "EUR",
"unit": "EUR/MWh",
"resolution_minutes": 60,
"prices": [
{"hour": 0, "price_eur_mwh": 48.0},
{"hour": 1, "price_eur_mwh": 51.0},
{
"interval_start_utc": "2026-04-02T00:00:00.000Z",
"interval_end_utc": "2026-04-02T01:00:00.000Z",
"price": 48.0,
},
{
"interval_start_utc": "2026-04-02T01:00:00.000Z",
"interval_end_utc": "2026-04-02T02:00:00.000Z",
"price": 51.0,
},
],
"stats": {"min": 48.0, "max": 51.0, "mean": 49.5},
"coverage": {
"expected_intervals": 2,
"returned_intervals": 2,
"missing_interval_starts": [],
"duplicates_dropped": 0,
},
"conflicts": 0,
}
elif name == "get_imbalance_prices":
payload = {
Expand Down
13 changes: 11 additions & 2 deletions python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ def test_convenience_methods_and_dataframe_helpers():
try:
prices = client.get_day_ahead_prices(zone="DE")
df = prices.to_pandas()
assert list(df.columns) == ["zone", "hour", "price_eur_mwh"]
assert list(df.columns) == [
"zone",
"currency",
"unit",
"resolution_minutes",
"conflicts",
"interval_start_utc",
"interval_end_utc",
"price",
]
assert df["zone"].tolist() == ["DE", "DE"]

generation = client.get_generation_mix(zone="DE")
Expand Down Expand Up @@ -340,7 +349,7 @@ def test_trading_price_helpers():

intraday = client.get_intraday_prices_frame(zone="GB")
assert len(intraday) == 2
assert intraday["price_eur_mwh"].tolist() == [48.0, 51.0]
assert intraday["price"].tolist() == [48.0, 51.0]

imbalance = client.get_imbalance_prices_frame(zone="GB")
assert len(imbalance) == 2
Expand Down
25 changes: 22 additions & 3 deletions python/tests/test_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,37 @@ def test_to_pandas_flattens_single_list_field_with_metadata():
raw={
"zone": "DE",
"prices": [
{"hour": 0, "price_eur_mwh": 42.0},
{"hour": 1, "price_eur_mwh": 43.5},
{"interval_start_utc": "2026-04-02T00:00:00.000Z", "price": 42.0},
{"interval_start_utc": "2026-04-02T01:00:00.000Z", "price": 43.5},
],
},
)

df = result.to_pandas()

assert list(df.columns) == ["zone", "hour", "price_eur_mwh"]
assert list(df.columns) == ["zone", "interval_start_utc", "price"]
assert df["zone"].tolist() == ["DE", "DE"]


def test_to_pandas_ignores_scalar_side_lists_like_notes():
result = LuminusResult(
tool_name="get_day_ahead_prices",
raw={
"zone": "GB",
"prices": [
{"interval_start_utc": "2026-04-02T00:00:00.000Z", "price": 42.0},
{"interval_start_utc": "2026-04-02T00:15:00.000Z", "price": 43.5},
],
"notes": ["Dropped 24 coarser-resolution point(s) overlapped by a finer series (finest 15min)."],
},
)

df = result.to_pandas()

assert len(df) == 2
assert df["price"].tolist() == [42.0, 43.5]


def test_to_pandas_wraps_scalar_dicts_as_single_row_frame():
result = LuminusResult(tool_name="get_server_status", raw={"activeProfile": "full", "registeredTools": 56})
df = result.to_pandas()
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ if (shouldRegister("get_day_ahead_prices")) {
registeredToolNames.push("get_day_ahead_prices");
server.tool(
"get_day_ahead_prices",
"Day-ahead hourly prices (EUR/MWh) for a European zone. Includes min/max/mean stats.",
"Day-ahead electricity prices for a European zone, interval-timestamped in the source currency/resolution. Includes min/max/mean stats and interval coverage.",
pricesSchema.shape,
auditedToolHandler("get_day_ahead_prices", pricesSchema, getDayAheadPrices),
);
Expand Down Expand Up @@ -403,7 +403,7 @@ if (shouldRegister("get_intraday_prices")) {
registeredToolNames.push("get_intraday_prices");
server.tool(
"get_intraday_prices",
"Intraday continuous electricity prices (EUR/MWh) for a European zone. Hourly with stats.",
"Intraday continuous electricity prices for a European zone, interval-timestamped in the source currency/resolution. Includes min/max/mean stats and interval coverage.",
intradayPricesSchema.shape,
auditedToolHandler("get_intraday_prices", intradayPricesSchema, getIntradayPrices),
);
Expand Down Expand Up @@ -433,7 +433,7 @@ if (shouldRegister("get_intraday_da_spread")) {
registeredToolNames.push("get_intraday_da_spread");
server.tool(
"get_intraday_da_spread",
"Intraday vs day-ahead spread per hour. Directional signal: premium = post-auction change (outage, forecast miss, demand spike).",
"Intraday vs day-ahead spread per interval. Directional signal: premium = post-auction change (outage, forecast miss, demand spike).",
intradaySpreadSchema.shape,
auditedToolHandler("get_intraday_da_spread", intradaySpreadSchema, getIntradayDaSpread),
);
Expand Down Expand Up @@ -485,7 +485,7 @@ if (shouldRegister("get_price_spread_analysis")) {
registeredToolNames.push("get_price_spread_analysis");
server.tool(
"get_price_spread_analysis",
"BESS arbitrage analysis: optimal charge/discharge schedule, revenue per MW, signal strength.",
"BESS arbitrage analysis: optimal charge/discharge schedule keyed by interval, revenue per MW, signal strength.",
priceSpreadAnalysisSchema.shape,
auditedToolHandler("get_price_spread_analysis", priceSpreadAnalysisSchema, getPriceSpreadAnalysis),
);
Expand Down
37 changes: 37 additions & 0 deletions src/lib/entsoe-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ export function dayRange(dateStr?: string): {
};
}

const MARKET_DAY_FMT = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Brussels",
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});

function brusselsOffsetMs(utcMs: number): number {
const parts = MARKET_DAY_FMT.formatToParts(new Date(utcMs));
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute")) - utcMs;
}

/** UTC instant of 00:00 Europe/Brussels on a YYYY-MM-DD date. */
export function marketMidnightUtc(dateStr: string): Date {
const utcMidnight = Date.parse(dateStr + "T00:00:00Z");
// Clocks change at 01:00Z, so the previous day's noon carries the offset in force at local midnight.
return new Date(utcMidnight - brusselsOffsetMs(utcMidnight - 12 * 3600000));
}

// ponytail: every zone luminus serves (SDAC, GB, SEM) auctions a CET delivery day; a zone on another day needs a per-zone tz.
/** ENTSO-E window for delivery days [startDate, endDate), endDate defaulting to the day after startDate. */
export function marketDayRange(startDate?: string, endDate?: string): {
periodStart: string;
periodEnd: string;
} {
const start = startDate ?? new Date().toISOString().slice(0, 10);
const end = endDate ?? new Date(Date.parse(start + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
return {
periodStart: formatEntsoeDate(marketMidnightUtc(start)),
periodEnd: formatEntsoeDate(marketMidnightUtc(end)),
};
}

/**
* Query the ENTSO-E API.
* Returns parsed XML as JS object.
Expand Down
Loading
Loading