Skip to content

Commit 4e60dcc

Browse files
feat(IN): add IEX day-ahead market price parser
Parse unconstrained DAM MCP (₹/MWh) from IEX's public provisional page (15-min blocks) and wire fetch_price for zone IN. Fixtures + unit/snapshot tests cover parsing and error paths. Historical backfill is out of scope for this endpoint; tracked in #8796. Fixes #8796
1 parent b47c731 commit 4e60dcc

5 files changed

Lines changed: 1217 additions & 0 deletions

File tree

config/zones/IN.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ contributors:
2222
- systemcatch
2323
- gopikrishna1793
2424
- unitrium
25+
- sankalpsthakur
2526
country: IN
2627
country_name: India
2728
currency: INR
@@ -144,6 +145,7 @@ fallbackZoneMixes:
144145
has_day_ahead_price_license: False
145146
hide_day_ahead_price: False
146147
parsers:
148+
price: IEX.fetch_price
147149
production: IN.fetch_production
148150
region: Asia
149151
subZoneNames:
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Indian Energy Exchange (IEX) Day-Ahead Market price parser.
2+
3+
Source: unconstrained DAM MCP published on IEX's provisional DAM page
4+
https://iexrtmprice.com/view-dam-provisional-mcv-and-mcp-data/
5+
(linked from https://www.iexindia.com/market-data/day-ahead-market/market-snapshot)
6+
7+
The page currently exposes the latest cleared delivery day as 15-minute
8+
blocks (MCP in ₹/MWh). Historical backfill is not available from this
9+
endpoint; see https://github.com/electricitymaps/electricitymaps-contrib/issues/8796
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from datetime import date, datetime, timedelta
16+
from logging import Logger, getLogger
17+
from typing import Any
18+
from zoneinfo import ZoneInfo
19+
20+
from bs4 import BeautifulSoup
21+
from requests import Response, Session
22+
23+
from electricitymap.contrib.lib.models.event_lists import PriceList
24+
from electricitymap.contrib.parsers.lib.config import refetch_frequency
25+
from electricitymap.contrib.parsers.lib.exceptions import ParserException
26+
from electricitymap.contrib.types import ZoneKey
27+
28+
TZ = ZoneInfo("Asia/Kolkata")
29+
SOURCE = "iexindia.com"
30+
CURRENCY = "INR"
31+
PARSER = "IEX.py"
32+
33+
# Provisional unconstrained DAM MCP/MCV table (no auth).
34+
DAM_PROVISIONAL_URL = "https://iexrtmprice.com/view-dam-provisional-mcv-and-mcp-data/"
35+
36+
# e.g. "00:00 - 00:15", "23:45 - 24:00"
37+
_TIME_BLOCK_RE = re.compile(
38+
r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})\s*-\s*(?P<eh>\d{1,2}):(?P<em>\d{2})$"
39+
)
40+
_DATE_RE = re.compile(r"(\d{2})-(\d{2})-(\d{4})")
41+
42+
43+
def _parse_delivery_date(text: str) -> date:
44+
match = _DATE_RE.search(text)
45+
if not match:
46+
raise ParserException(
47+
PARSER,
48+
f"Could not parse delivery date from: {text!r}",
49+
)
50+
day, month, year = (int(match.group(i)) for i in range(1, 4))
51+
return date(year, month, day)
52+
53+
54+
def _block_bounds(delivery_date: date, time_block: str) -> tuple[datetime, datetime]:
55+
"""Return (start, end) datetimes in Asia/Kolkata for a DAM time block."""
56+
match = _TIME_BLOCK_RE.match(time_block.strip())
57+
if not match:
58+
raise ValueError(f"Unrecognised time block: {time_block!r}")
59+
60+
sh, sm = int(match.group("sh")), int(match.group("sm"))
61+
eh, em = int(match.group("eh")), int(match.group("em"))
62+
63+
start = datetime(
64+
delivery_date.year,
65+
delivery_date.month,
66+
delivery_date.day,
67+
sh,
68+
sm,
69+
tzinfo=TZ,
70+
)
71+
if eh == 24 and em == 0:
72+
end = datetime(
73+
delivery_date.year,
74+
delivery_date.month,
75+
delivery_date.day,
76+
tzinfo=TZ,
77+
) + timedelta(days=1)
78+
else:
79+
end = datetime(
80+
delivery_date.year,
81+
delivery_date.month,
82+
delivery_date.day,
83+
eh,
84+
em,
85+
tzinfo=TZ,
86+
)
87+
return start, end
88+
89+
90+
def _parse_dam_html(
91+
html: str, logger: Logger
92+
) -> tuple[date, list[tuple[datetime, datetime, float]]]:
93+
"""Parse provisional DAM HTML into (delivery_date, [(start, end, price), ...])."""
94+
soup = BeautifulSoup(html, "html.parser")
95+
96+
heading = soup.find("h1")
97+
if heading is None:
98+
raise ParserException(PARSER, "Missing <h1> with delivery date on DAM page")
99+
delivery_date = _parse_delivery_date(heading.get_text(" ", strip=True))
100+
101+
table = soup.find("table")
102+
if table is None:
103+
raise ParserException(PARSER, "Missing DAM price table")
104+
105+
rows: list[tuple[datetime, datetime, float]] = []
106+
for tr in table.find_all("tr"):
107+
cells = [c.get_text(strip=True) for c in tr.find_all("td")]
108+
if len(cells) < 2:
109+
continue
110+
time_block, mcp_raw = cells[0], cells[1]
111+
if not _TIME_BLOCK_RE.match(time_block):
112+
# Skip header leftovers / Max / Average / Sum summary rows.
113+
continue
114+
if not mcp_raw or mcp_raw in {"-", "NA", "N/A"}:
115+
logger.warning("Skipping DAM block %s with empty MCP", time_block)
116+
continue
117+
try:
118+
price = float(mcp_raw.replace(",", ""))
119+
except ValueError:
120+
logger.warning(
121+
"Skipping DAM block %s with non-numeric MCP %r", time_block, mcp_raw
122+
)
123+
continue
124+
start, end = _block_bounds(delivery_date, time_block)
125+
rows.append((start, end, price))
126+
127+
if not rows:
128+
raise ParserException(PARSER, "No DAM time-block rows found in HTML")
129+
130+
return delivery_date, rows
131+
132+
133+
def _fetch_dam_html(session: Session) -> str:
134+
response: Response = session.get(DAM_PROVISIONAL_URL, timeout=30)
135+
if not response.ok:
136+
raise ParserException(
137+
PARSER,
138+
f"{DAM_PROVISIONAL_URL} returned HTTP {response.status_code}",
139+
)
140+
return response.text
141+
142+
143+
@refetch_frequency(timedelta(days=1))
144+
def fetch_price(
145+
zone_key: ZoneKey = ZoneKey("IN"),
146+
session: Session | None = None,
147+
target_datetime: datetime | None = None,
148+
logger: Logger = getLogger(__name__),
149+
) -> list[dict[str, Any]]:
150+
"""Fetch IEX unconstrained Day-Ahead Market clearing prices (₹/MWh).
151+
152+
The public provisional endpoint only exposes the latest cleared delivery
153+
day. Historical `target_datetime` queries that do not match that day raise
154+
``ParserException``.
155+
"""
156+
session = session or Session()
157+
html = _fetch_dam_html(session)
158+
delivery_date, rows = _parse_dam_html(html, logger)
159+
160+
if target_datetime is not None:
161+
if target_datetime.tzinfo is None:
162+
raise ParserException(
163+
PARSER,
164+
"target_datetime must be timezone-aware",
165+
zone_key,
166+
)
167+
requested = target_datetime.astimezone(TZ).date()
168+
if requested != delivery_date:
169+
raise ParserException(
170+
PARSER,
171+
(
172+
f"Historical DAM prices are not available from the provisional "
173+
f"endpoint (requested {requested.isoformat()}, page has "
174+
f"{delivery_date.isoformat()}). See issue #8796 for backfill."
175+
),
176+
zone_key,
177+
)
178+
179+
price_list = PriceList(logger)
180+
for start, end, price in rows:
181+
price_list.append(
182+
zoneKey=zone_key,
183+
datetime=start,
184+
end_datetime=end,
185+
price=price,
186+
currency=CURRENCY,
187+
source=SOURCE,
188+
)
189+
return price_list.to_list()
190+
191+
192+
if __name__ == "__main__":
193+
print("fetch_price() ->")
194+
print(fetch_price())

0 commit comments

Comments
 (0)