|
| 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