import io
import math
import re
from heapq import merge
from tabnanny import verbose
import pandas as pd
import requests
import gridstatus
from gridstatus import utils
from gridstatus.base import (
FuelMix,
GridStatus,
InterconnectionQueueStatus,
ISOBase,
Markets,
NotSupported,
)
from gridstatus.decorators import support_date_range
[docs]class ISONE(ISOBase):
"""ISO New England (ISONE)"""
name = "ISO New England"
iso_id = "isone"
default_timezone = "US/Eastern"
status_homepage = "https://www.iso-ne.com/markets-operations/system-forecast-status/current-system-status"
interconnection_homepage = "https://irtt.iso-ne.com/reports/external"
markets = [
Markets.REAL_TIME_5_MIN,
Markets.REAL_TIME_HOURLY,
Markets.DAY_AHEAD_HOURLY,
]
hubs = {"H.INTERNAL_HUB": 4000}
zones = {
".Z.MAINE": 4001,
".Z.NEWHAMPSHIRE": 4002,
".Z.VERMONT": 4003,
".Z.CONNECTICUT": 4004,
".Z.RHODEISLAND": 4005,
".Z.SEMASS": 4006,
".Z.WCMASS": 4007,
".Z.NEMASSBOST": 4008,
}
interfaces = {
".I.SALBRYNB345": 4010,
".I.ROSETON 345": 4011,
".I.HQ_P1_P2345": 4012,
".I.HQHIGATE120": 4013,
".I.SHOREHAM138": 4014,
".I.NRTHPORT138": 4017,
}
[docs] def get_status(self, date, verbose=False):
"""Get latest status for ISO NE"""
if date != "latest":
raise NotSupported()
# historical data available
# https://www.iso-ne.com/markets-operations/system-forecast-status/current-system-status/power-system-status-list
data = _make_wsclient_request(
url="https://www.iso-ne.com/ws/wsclient",
data={
"_nstmp_requestType": "systemconditions",
"_nstmp_requestUrl": "/powersystemconditions/current",
},
)
# looks like it could return multiple entries
condition = data[0]["data"]["PowerSystemConditions"]["PowerSystemCondition"][0]
status = condition["SystemCondition"]
note = condition["ActionDescription"]
time = pd.Timestamp.now(tz=self.default_timezone).floor(freq="s")
return GridStatus(
time=time,
status=status,
reserves=None,
iso=self,
notes=[note],
)
def _get_latest_fuel_mix(self):
data = _make_wsclient_request(
url="https://www.iso-ne.com/ws/wsclient",
data={"_nstmp_requestType": "fuelmix"},
)
mix_df = pd.DataFrame(data[0]["data"]["GenFuelMixes"]["GenFuelMix"])
time = pd.Timestamp(
mix_df["BeginDate"].max(),
tz=self.default_timezone,
)
# todo has marginal flag
mix_dict = mix_df.set_index("FuelCategory")["GenMw"].to_dict()
return FuelMix(time, mix_dict, self.name)
@support_date_range(frequency="1D")
[docs] def get_fuel_mix(self, date, end=None, verbose=False):
"""Return fuel mix at a previous date
Provided at frequent, but irregular intervals by ISONE
"""
if date == "latest":
return self._get_latest_fuel_mix()
# todo should getting day today use the latest endpoint?
url = "https://www.iso-ne.com/transform/csv/genfuelmix?start=" + date.strftime(
"%Y%m%d",
)
df = _make_request(url, skiprows=[0, 1, 2, 3, 5])
df["Date"] = pd.to_datetime(df["Date"] + " " + df["Time"]).dt.tz_localize(
self.default_timezone,
)
mix_df = df.pivot_table(
index="Date",
columns="Fuel Category",
values="Gen Mw",
aggfunc="first",
).reset_index()
mix_df = mix_df.rename(columns={"Date": "Time"})
return mix_df
@support_date_range(frequency="1D")
[docs] def get_load(self, date):
"""Return load at a previous date in 5 minute intervals"""
# todo document the earliest supported date
# supports a start and end date
if date == "latest":
return self._latest_from_today(self.get_load)
date_str = date.strftime("%Y%m%d")
url = f"https://www.iso-ne.com/transform/csv/fiveminutesystemload?start={date_str}&end={date_str}"
data = _make_request(url, skiprows=[0, 1, 2, 3, 5])
data["Date/Time"] = pd.to_datetime(data["Date/Time"]).dt.tz_localize(
self.default_timezone,
)
df = data[["Date/Time", "Native Load"]].rename(
columns={"Date/Time": "Time", "Native Load": "Load"},
)
return df
@support_date_range(frequency="1D")
[docs] def get_supply(self, date, end=None, verbose=False):
"""Get supply for a date or date range in hourly intervals"""
return self._get_supply(date=date, end=end, verbose=verbose)
@support_date_range(frequency="1D")
[docs] def get_load_forecast(self, date, end=None, verbose=False):
"""Return forecast at a previous date"""
start_str = date.strftime("%m/%d/%Y")
end_str = (date + pd.Timedelta(days=1)).strftime("%m/%d/%Y")
data = {
"_nstmp_startDate": start_str,
"_nstmp_endDate": end_str,
"_nstmp_twodays": True,
"_nstmp_twodaysCheckbox": False,
"_nstmp_requestType": "systemload",
"_nstmp_forecast": True,
"_nstmp_actual": False,
"_nstmp_cleared": False,
"_nstmp_priorDay": False,
"_nstmp_inclPumpLoad": True,
"_nstmp_inclBtmPv": True,
}
data = _make_wsclient_request(
url="https://www.iso-ne.com/ws/wsclient",
data=data,
)
data = pd.DataFrame(data[0]["data"]["forecast"])
data["BeginDate"] = pd.to_datetime(data["BeginDate"]).dt.tz_convert(
self.default_timezone,
)
data["CreationDate"] = pd.to_datetime(data["CreationDate"]).dt.tz_convert(
self.default_timezone,
)
df = data[["CreationDate", "BeginDate", "Mw"]].rename(
columns={
"CreationDate": "Forecast Time",
"BeginDate": "Time",
"Mw": "Load Forecast",
},
)
return df
def _get_latest_lmp(self, market: str, locations: list = None, verbose=False):
"""
Find Node ID mapping: https://www.iso-ne.com/markets-operations/settlements/pricing-node-tables/
"""
if locations is None:
locations = "ALL"
market = Markets(market)
if market == Markets.REAL_TIME_5_MIN:
url = "https://www.iso-ne.com/transform/csv/fiveminlmp/current?type=prelim"
data = _make_request(url, skiprows=[0, 1, 2, 4])
elif market == Markets.REAL_TIME_HOURLY:
url = "https://www.iso-ne.com/transform/csv/hourlylmp/current?type=prelim&market=rt"
data = _make_request(url, skiprows=[0, 1, 2, 4])
# todo does this handle single digital hours?
data["Local Time"] = (
data["Local Date"]
+ " "
+ data["Local Time"].astype(str).str.zfill(2)
+ ":00"
)
else:
raise RuntimeError("LMP Market is not supported")
data = self._process_lmp(
data,
market,
self.default_timezone,
locations,
)
return data
@support_date_range(frequency="1D")
[docs] def get_lmp(
self,
date,
end=None,
market: str = None,
locations: list = None,
include_id=False,
verbose=False,
):
"""Find Node ID mapping: https://www.iso-ne.com/markets-operations/settlements/pricing-node-tables/"""
if date == "latest":
return self._get_latest_lmp(
market=market,
locations=locations,
verbose=verbose,
)
date_str = date.strftime("%Y%m%d")
if locations is None:
locations = "ALL"
now = pd.Timestamp.now(tz=self.default_timezone)
market = Markets(market)
if market == Markets.REAL_TIME_5_MIN:
# todo handle intervals for current day
intervals = ["00-04", "04-08", "08-12", "12-16", "16-20", "20-24"]
# optimze for current day
if now.date() == date.date():
hour = now.hour
# select completed 4 hour intervals based on current hour
intervals = intervals[: math.ceil((hour + 1) / 4) - 1]
dfs = []
for interval in intervals:
print("Loading interval {}".format(interval))
u = f"https://www.iso-ne.com/static-transform/csv/histRpts/5min-rt-prelim/lmp_5min_{date_str}_{interval}.csv"
dfs.append(
pd.read_csv(
u,
skiprows=[0, 1, 2, 3, 5],
skipfooter=1,
engine="python",
),
)
data = pd.concat(dfs)
data["Local Time"] = (
date.strftime(
"%Y-%m-%d",
)
+ " "
+ data["Local Time"]
)
# add current interval
if now.date() == date.date():
url = "https://www.iso-ne.com/transform/csv/fiveminlmp/currentrollinginterval"
print("Loading current interval")
# this request is very very slow for some reason. I suspect because the server is making the response dynamically
data_current = _make_request(url, skiprows=[0, 1, 2, 4])
data_current = data_current[
data_current["Local Time"] > data["Local Time"].max()
]
data = pd.concat([data, data_current])
elif market == Markets.REAL_TIME_HOURLY:
if date.date() < now.date():
url = f"https://www.iso-ne.com/static-transform/csv/histRpts/rt-lmp/lmp_rt_prelim_{date_str}.csv"
data = _make_request(url, skiprows=[0, 1, 2, 3, 5])
# todo document hour starting vs ending
data["Local Time"] = (
data["Date"]
+ " "
+ (data["Hour Ending"] - 1).astype(str).str.zfill(2)
+ ":00"
)
else:
raise RuntimeError(
"Today not supported for hourly lmp. Try latest",
)
elif market == Markets.DAY_AHEAD_HOURLY:
url = f"https://www.iso-ne.com/static-transform/csv/histRpts/da-lmp/WW_DALMP_ISO_{date_str}.csv"
data = _make_request(url, skiprows=[0, 1, 2, 3, 5])
# todo document hour starting vs ending
data["Local Time"] = (
data["Date"]
+ " "
+ (data["Hour Ending"] - 1).astype(str).str.zfill(2)
+ ":00"
)
else:
raise RuntimeError("LMP Market is not supported")
data = self._process_lmp(
data,
market,
self.default_timezone,
locations,
include_id=include_id,
)
return data
# daily historical fuel mix
# https://www.iso-ne.com/static-assets/documents/2022/01/2022_daygenbyfuel.xlsx
# a bunch more here: https://www.iso-ne.com/isoexpress/web/reports/operations/-/tree/daily-gen-fuel-type
def _process_lmp(self, data, market, timezone, locations, include_id=False):
# each market returns a slight different set of columns
# real time 5 minute has "Location ID"
# real time hourly has "Location" that represent location name
# day ahead hourly has "Location ID" and "Location Name
rename = {
"Location Name": "Location",
"Location ID": "Location Id",
"Location Type": "Location Type",
"Local Time": "Time",
"Locational Marginal Price": "LMP",
"LMP": "LMP",
"Energy Component": "Energy",
"Congestion Component": "Congestion",
"Loss Component": "Loss",
"Marginal Loss Component": "Loss",
}
data.rename(columns=rename, inplace=True)
data["Market"] = market.value
data["Time"] = pd.to_datetime(data["Time"]).dt.tz_localize(timezone)
# handle missing location information for some markets
if market != Markets.DAY_AHEAD_HOURLY:
day_ahead = self.get_lmp(
date="today",
market=Markets.DAY_AHEAD_HOURLY,
locations=locations,
include_id=True,
)
location_mapping = day_ahead.drop_duplicates("Location Id")[
["Location", "Location Id", "Location Type"]
]
if "Location Id" in data.columns:
data = data.merge(
location_mapping,
how="left",
on="Location Id",
)
elif "Location" in data.columns:
data = data.merge(location_mapping, how="left", on="Location")
data = data[
[
"Time",
"Market",
"Location",
"Location Id",
"Location Type",
"LMP",
"Energy",
"Congestion",
"Loss",
]
]
if not include_id:
data.drop(columns=["Location Id"], inplace=True)
data = utils.filter_lmp_locations(data, locations)
return data
[docs] def get_interconnection_queue(self, verbose=False):
"""Get the interconnection queue. Contains active and withdrawm applications.
More information: https://www.iso-ne.com/system-planning/interconnection-service/interconnection-request-queue/
Returns:
pd.DataFrame -- interconnection queue
"""
# not sure what the reportdate value is. it is hardcode into the javascript to add and doesnt work without
url = "https://irtt.iso-ne.com/reports/exportpublicqueue?ReportDate=638005248000000000&Status=&Jurisdiction="
if verbose:
print("Loading interconnection queue from {}".format(url))
r = requests.get(url)
queue = pd.read_excel(io.BytesIO(r.content), skiprows=4)
# only keep generator interconnection requests
queue = queue[queue["Type"] == "G"]
queue["Status"] = (
queue["W/ D Date"]
.isna()
.map(
{
True: InterconnectionQueueStatus.WITHDRAWN.value,
False: InterconnectionQueueStatus.ACTIVE.value,
},
)
)
queue["Proposed Completion Date"] = queue["Sync Date"]
rename = {
"Position": "Queue ID",
"Alternative Name": "Project Name",
"Fuel Type": "Generation Type",
"Requested": "Queue Date",
"County": "County",
"State": "State",
"Status": "Status",
"Interconnection Location": "Interconnection Location",
"W/ D Date": "Withdrawn Date",
"Net MW": "Capacity (MW)",
"Summer MW": "Summer Capacity (MW)",
"Winter MW": "Winter Capacity (MW)",
"TO Report": "Transmission Owner",
}
# todo: there are a few columns being parsed as "unamed" that aren't being included but should
extra_columns = [
"Updated",
"Unit",
"Op Date",
"Sync Date",
"Serv",
"SIS Complete",
"I39",
"Dev",
"Zone",
"FS",
"SIS",
"OS",
"FAC",
"IA",
"Project Status",
]
missing = [
"Interconnecting Entity",
"Actual Completion Date", # because there are only activate and withdrawn projects
"Withdrawal Comment",
]
queue = utils.format_interconnection_df(
queue=queue,
rename=rename,
extra=extra_columns,
missing=missing,
)
queue = queue.sort_values("Queue ID").reset_index(drop=True)
return queue
def _make_request(url, skiprows):
with requests.Session() as s:
# in testing, never takes more than 2 attempts
attempt = 0
while attempt < 3:
# make first get request to get cookies set
r1 = s.get(
"https://www.iso-ne.com/isoexpress/web/reports/operations/-/tree/gen-fuel-mix",
)
r2 = s.get(url)
if r2.status_code == 200:
break
print("Attempt {} failed. Retrying...".format(attempt + 1))
attempt += 1
if r2.status_code != 200:
raise RuntimeError(
"Failed to get data from {}. Check if ISONE is down and try again later".format(
url,
),
)
df = pd.read_csv(
io.StringIO(r2.content.decode("utf8")),
skiprows=skiprows,
skipfooter=1,
engine="python",
)
return df
def _make_wsclient_request(url, data, verbose=False):
"""Make request to ISO NE wsclient"""
if verbose:
print("Requesting data from {}".format(url))
r = requests.post(
"https://www.iso-ne.com/ws/wsclient",
data=data,
)
if r.status_code != 200:
raise RuntimeError(
"Failed to get data from {}. Check if ISONE is down and try again later".format(
url,
),
)
return r.json()