Skip to content

Commit 32396c3

Browse files
Handle row-spanned IEX DAM tables
1 parent c2c7bd2 commit 32396c3

1 file changed

Lines changed: 83 additions & 12 deletions

File tree

  • electricitymap/contrib/parsers

electricitymap/contrib/parsers/IEX.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})\s*-\s*(?P<eh>\d{1,2}):(?P<em>\d{2})$"
4949
)
5050
_DATE_RE = re.compile(r"(\d{2})-(\d{2})-(\d{4})")
51+
_MISSING_PRICE_MARKERS = {"", "-", "NA", "N/A"}
5152

5253

5354
def _parse_delivery_date(text: str) -> date:
@@ -97,31 +98,99 @@ def _block_bounds(delivery_date: date, time_block: str) -> tuple[datetime, datet
9798
return start, end
9899

99100

101+
def _find_dam_table(soup: BeautifulSoup):
102+
"""Find the DAM table and return it with the Time Block and MCP indexes."""
103+
for table in soup.find_all("table"):
104+
for tr in table.find_all("tr"):
105+
header_cells = tr.find_all(["th", "td"])
106+
if not header_cells:
107+
continue
108+
headers = [
109+
cell.get_text(" ", strip=True).casefold() for cell in header_cells
110+
]
111+
time_block_index = next(
112+
(i for i, header in enumerate(headers) if "time block" in header),
113+
None,
114+
)
115+
mcp_index = next(
116+
(i for i, header in enumerate(headers) if "mcp" in header), None
117+
)
118+
if time_block_index is not None and mcp_index is not None:
119+
return table, time_block_index, mcp_index
120+
121+
raise ParserException(PARSER, "Missing DAM price table with Time Block and MCP columns")
122+
123+
100124
def _parse_dam_html(
101125
html: str, logger: Logger
102126
) -> tuple[date, list[tuple[datetime, datetime, float]]]:
103-
"""Parse provisional DAM HTML into (delivery_date, [(start, end, price), ...])."""
127+
"""Parse provisional DAM HTML into (delivery_date, [(start, end, price), ...]).
128+
129+
IEX pages have used both a dated heading and row-spanned delivery-date cells.
130+
The parser supports both layouts and derives the MCP position relative to the
131+
Time Block column, so date and hour row spans before the time block do not
132+
shift the price lookup.
133+
"""
104134
soup = BeautifulSoup(html, "html.parser")
105135

136+
delivery_date: date | None = None
106137
heading = soup.find("h1")
107-
if heading is None:
108-
raise ParserException(PARSER, "Missing <h1> with delivery date on DAM page")
109-
delivery_date = _parse_delivery_date(heading.get_text(" ", strip=True))
138+
if heading is not None:
139+
heading_text = heading.get_text(" ", strip=True)
140+
if _DATE_RE.search(heading_text):
141+
delivery_date = _parse_delivery_date(heading_text)
110142

111-
table = soup.find("table")
112-
if table is None:
113-
raise ParserException(PARSER, "Missing DAM price table")
143+
table, time_block_column, mcp_column = _find_dam_table(soup)
144+
mcp_offset = mcp_column - time_block_column
114145

115146
rows: list[tuple[datetime, datetime, float]] = []
116147
for tr in table.find_all("tr"):
117-
cells = [c.get_text(strip=True) for c in tr.find_all("td")]
118-
if len(cells) < 2:
148+
cells = [c.get_text(" ", strip=True) for c in tr.find_all("td")]
149+
if not cells:
119150
continue
120-
time_block, mcp_raw = cells[0], cells[1]
121-
if not _TIME_BLOCK_RE.match(time_block):
151+
152+
date_cell = next(
153+
(cell for cell in cells if _DATE_RE.fullmatch(cell.strip())), None
154+
)
155+
if date_cell is not None:
156+
row_delivery_date = _parse_delivery_date(date_cell)
157+
if delivery_date is not None and row_delivery_date != delivery_date:
158+
raise ParserException(
159+
PARSER,
160+
(
161+
"DAM table contains multiple delivery dates: "
162+
f"{delivery_date.isoformat()} and {row_delivery_date.isoformat()}"
163+
),
164+
)
165+
delivery_date = row_delivery_date
166+
167+
time_block_index = next(
168+
(
169+
i
170+
for i, cell in enumerate(cells)
171+
if _TIME_BLOCK_RE.fullmatch(cell.strip())
172+
),
173+
None,
174+
)
175+
if time_block_index is None:
122176
# Skip header leftovers / Max / Average / Sum summary rows.
123177
continue
124-
if not mcp_raw or mcp_raw in {"-", "NA", "N/A"}:
178+
if delivery_date is None:
179+
raise ParserException(
180+
PARSER,
181+
"DAM time-block row appeared before a delivery date",
182+
)
183+
184+
mcp_index = time_block_index + mcp_offset
185+
if mcp_index < 0 or mcp_index >= len(cells):
186+
raise ParserException(
187+
PARSER,
188+
f"DAM row for {cells[time_block_index]!r} is missing its MCP cell",
189+
)
190+
191+
time_block = cells[time_block_index].strip()
192+
mcp_raw = cells[mcp_index].strip()
193+
if mcp_raw.upper() in _MISSING_PRICE_MARKERS:
125194
logger.warning("Skipping DAM block %s with empty MCP", time_block)
126195
continue
127196
try:
@@ -134,6 +203,8 @@ def _parse_dam_html(
134203
start, end = _block_bounds(delivery_date, time_block)
135204
rows.append((start, end, price))
136205

206+
if delivery_date is None:
207+
raise ParserException(PARSER, "No delivery date found in DAM heading or rows")
137208
if not rows:
138209
raise ParserException(PARSER, "No DAM time-block rows found in HTML")
139210

0 commit comments

Comments
 (0)