python-midas 0.1.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- midas/__init__.py +19 -6
- midas/auth.py +1 -3
- midas/client.py +48 -58
- midas/entities/__init__.py +18 -25
- midas/entities/models.py +91 -64
- midas/enums.py +38 -6
- midas/time.py +101 -0
- python_midas-1.0.0.dist-info/METADATA +417 -0
- python_midas-1.0.0.dist-info/RECORD +12 -0
- {python_midas-0.1.0.dist-info → python_midas-1.0.0.dist-info}/WHEEL +1 -1
- python_midas-0.1.0.dist-info/METADATA +0 -404
- python_midas-0.1.0.dist-info/RECORD +0 -11
- {python_midas-0.1.0.dist-info → python_midas-1.0.0.dist-info}/licenses/LICENSE +0 -0
midas/__init__.py
CHANGED
|
@@ -5,32 +5,40 @@ from midas.client import (
|
|
|
5
5
|
API_URL,
|
|
6
6
|
MIDASClient,
|
|
7
7
|
body,
|
|
8
|
+
create_anonymous_client,
|
|
8
9
|
create_auto_client,
|
|
9
10
|
create_client,
|
|
10
11
|
success,
|
|
11
12
|
)
|
|
12
13
|
from midas.entities import (
|
|
13
|
-
coerce_historical_list,
|
|
14
|
-
coerce_holidays,
|
|
15
14
|
coerce_lookup_table,
|
|
16
15
|
coerce_rate_info,
|
|
17
16
|
coerce_rin_list,
|
|
18
17
|
)
|
|
19
18
|
from midas.entities.models import (
|
|
20
|
-
Holiday,
|
|
21
19
|
LookupEntry,
|
|
20
|
+
LookupTableResponse,
|
|
22
21
|
MIDASBase,
|
|
23
22
|
RateInfo,
|
|
24
23
|
RinListEntry,
|
|
24
|
+
RinListResponse,
|
|
25
25
|
ValueData,
|
|
26
26
|
)
|
|
27
27
|
from midas.enums import DayType, RateType, SignalType, Unit
|
|
28
|
+
from midas.time import (
|
|
29
|
+
MIDAS_ZONE,
|
|
30
|
+
PendulumDateTime,
|
|
31
|
+
parse_instant,
|
|
32
|
+
parse_local,
|
|
33
|
+
parse_value_moment,
|
|
34
|
+
)
|
|
28
35
|
|
|
29
36
|
__all__ = [
|
|
30
37
|
# Client
|
|
31
38
|
"MIDASClient",
|
|
32
39
|
"create_client",
|
|
33
40
|
"create_auto_client",
|
|
41
|
+
"create_anonymous_client",
|
|
34
42
|
"success",
|
|
35
43
|
"body",
|
|
36
44
|
"API_URL",
|
|
@@ -43,19 +51,24 @@ __all__ = [
|
|
|
43
51
|
# Entity coercion
|
|
44
52
|
"coerce_rate_info",
|
|
45
53
|
"coerce_rin_list",
|
|
46
|
-
"coerce_holidays",
|
|
47
54
|
"coerce_lookup_table",
|
|
48
|
-
"coerce_historical_list",
|
|
49
55
|
# Entity models
|
|
50
56
|
"MIDASBase",
|
|
51
57
|
"RateInfo",
|
|
52
58
|
"ValueData",
|
|
53
59
|
"RinListEntry",
|
|
54
|
-
"
|
|
60
|
+
"RinListResponse",
|
|
55
61
|
"LookupEntry",
|
|
62
|
+
"LookupTableResponse",
|
|
56
63
|
# Enums
|
|
57
64
|
"SignalType",
|
|
58
65
|
"RateType",
|
|
59
66
|
"Unit",
|
|
60
67
|
"DayType",
|
|
68
|
+
# Time
|
|
69
|
+
"MIDAS_ZONE",
|
|
70
|
+
"PendulumDateTime",
|
|
71
|
+
"parse_instant",
|
|
72
|
+
"parse_local",
|
|
73
|
+
"parse_value_moment",
|
|
61
74
|
]
|
midas/auth.py
CHANGED
|
@@ -73,9 +73,7 @@ def get_token(
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def token_expired(
|
|
77
|
-
token_info: dict[str, Any], buffer_seconds: int = 30
|
|
78
|
-
) -> bool:
|
|
76
|
+
def token_expired(token_info: dict[str, Any], buffer_seconds: int = 30) -> bool:
|
|
79
77
|
"""True if a token-info dict is expired or will expire within buffer_seconds."""
|
|
80
78
|
expires_at = token_info.get("expires_at")
|
|
81
79
|
if expires_at is None:
|
midas/client.py
CHANGED
|
@@ -5,17 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
|
+
import pendulum
|
|
8
9
|
|
|
9
10
|
from midas.auth import AutoTokenAuth, BearerAuth, get_token
|
|
10
11
|
from midas.entities import (
|
|
11
|
-
coerce_historical_list,
|
|
12
|
-
coerce_holidays,
|
|
13
12
|
coerce_lookup_table,
|
|
14
13
|
coerce_rate_info,
|
|
15
14
|
coerce_rin_list,
|
|
16
15
|
)
|
|
17
16
|
from midas.entities.models import (
|
|
18
|
-
Holiday,
|
|
19
17
|
LookupEntry,
|
|
20
18
|
RateInfo,
|
|
21
19
|
RinListEntry,
|
|
@@ -35,6 +33,17 @@ def body(resp: httpx.Response) -> Any:
|
|
|
35
33
|
return resp.json()
|
|
36
34
|
|
|
37
35
|
|
|
36
|
+
def _check_historical_range(start_date: str, end_date: str) -> None:
|
|
37
|
+
"""Enforce the v2.0 6-month max range for a single historical-data call."""
|
|
38
|
+
start = pendulum.parse(start_date)
|
|
39
|
+
end = pendulum.parse(end_date)
|
|
40
|
+
if end > start.add(months=6):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"MIDAS v2.0 limits /HistoricalData to a 6-month range per call; "
|
|
43
|
+
f"requested {start_date}..{end_date}. Split into multiple calls."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
38
47
|
class MIDASClient:
|
|
39
48
|
"""MIDAS API HTTP client with raw and coerced methods."""
|
|
40
49
|
|
|
@@ -48,7 +57,9 @@ class MIDASClient:
|
|
|
48
57
|
self.base_url = base_url.rstrip("/")
|
|
49
58
|
_timeout = httpx.Timeout(timeout, connect=timeout)
|
|
50
59
|
if auth:
|
|
51
|
-
self._http = httpx.Client(
|
|
60
|
+
self._http = httpx.Client(
|
|
61
|
+
base_url=self.base_url, auth=auth, timeout=_timeout
|
|
62
|
+
)
|
|
52
63
|
elif token:
|
|
53
64
|
self._http = httpx.Client(
|
|
54
65
|
base_url=self.base_url, auth=BearerAuth(token), timeout=_timeout
|
|
@@ -72,43 +83,26 @@ class MIDASClient:
|
|
|
72
83
|
"""Fetch list of available RINs by signal type (0=All, 1=Rates, 2=GHG, 3=Flex Alert)."""
|
|
73
84
|
return self._http.get("/ValueData", params={"SignalType": signal_type})
|
|
74
85
|
|
|
75
|
-
def get_rate_values(
|
|
76
|
-
self, rin: str, query_type: str = "alldata"
|
|
77
|
-
) -> httpx.Response:
|
|
86
|
+
def get_rate_values(self, rin: str, query_type: str = "alldata") -> httpx.Response:
|
|
78
87
|
"""Fetch rate/price values for a specific RIN."""
|
|
79
|
-
return self._http.get(
|
|
80
|
-
"/ValueData", params={"ID": rin, "QueryType": query_type}
|
|
81
|
-
)
|
|
88
|
+
return self._http.get("/ValueData", params={"ID": rin, "QueryType": query_type})
|
|
82
89
|
|
|
83
90
|
def get_lookup_table(self, table_name: str) -> httpx.Response:
|
|
84
91
|
"""Fetch a MIDAS lookup/reference table."""
|
|
85
|
-
return self._http.get(
|
|
86
|
-
"/ValueData", params={"LookupTable": table_name}
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def get_holidays(self) -> httpx.Response:
|
|
90
|
-
"""Fetch all utility holidays."""
|
|
91
|
-
return self._http.get("/Holiday")
|
|
92
|
-
|
|
93
|
-
def get_historical_list(
|
|
94
|
-
self, distribution_code: str, energy_code: str
|
|
95
|
-
) -> httpx.Response:
|
|
96
|
-
"""Fetch list of RINs with historical data for a provider pair."""
|
|
97
|
-
return self._http.get(
|
|
98
|
-
"/HistoricalList",
|
|
99
|
-
params={
|
|
100
|
-
"DistributionCode": distribution_code,
|
|
101
|
-
"EnergyCode": energy_code,
|
|
102
|
-
},
|
|
103
|
-
)
|
|
92
|
+
return self._http.get("/ValueData", params={"LookupTable": table_name})
|
|
104
93
|
|
|
105
94
|
def get_historical_data(
|
|
106
95
|
self, rin: str, start_date: str, end_date: str
|
|
107
96
|
) -> httpx.Response:
|
|
108
|
-
"""Fetch archived rate data for a RIN within a date range.
|
|
97
|
+
"""Fetch archived rate data for a RIN within a date range.
|
|
98
|
+
|
|
99
|
+
v2.0 takes the RIN as a path parameter (``/HistoricalData/{rate_id}``)
|
|
100
|
+
and caps each call at a 6-month range.
|
|
101
|
+
"""
|
|
102
|
+
_check_historical_range(start_date, end_date)
|
|
109
103
|
return self._http.get(
|
|
110
|
-
"/HistoricalData",
|
|
111
|
-
params={"
|
|
104
|
+
f"/HistoricalData/{rin}",
|
|
105
|
+
params={"startdate": start_date, "enddate": end_date},
|
|
112
106
|
)
|
|
113
107
|
|
|
114
108
|
# -- Coerced methods (return typed models) --
|
|
@@ -117,11 +111,9 @@ class MIDASClient:
|
|
|
117
111
|
"""Fetch and coerce RIN list."""
|
|
118
112
|
resp = self.get_rin_list(signal_type)
|
|
119
113
|
resp.raise_for_status()
|
|
120
|
-
return coerce_rin_list(resp.json())
|
|
114
|
+
return coerce_rin_list(resp.json(), signal_type)
|
|
121
115
|
|
|
122
|
-
def rate_values(
|
|
123
|
-
self, rin: str, query_type: str = "alldata"
|
|
124
|
-
) -> RateInfo:
|
|
116
|
+
def rate_values(self, rin: str, query_type: str = "alldata") -> RateInfo:
|
|
125
117
|
"""Fetch and coerce rate values for a specific RIN."""
|
|
126
118
|
resp = self.get_rate_values(rin, query_type)
|
|
127
119
|
resp.raise_for_status()
|
|
@@ -133,23 +125,7 @@ class MIDASClient:
|
|
|
133
125
|
resp.raise_for_status()
|
|
134
126
|
return coerce_lookup_table(resp.json())
|
|
135
127
|
|
|
136
|
-
def
|
|
137
|
-
"""Fetch and coerce holidays."""
|
|
138
|
-
resp = self.get_holidays()
|
|
139
|
-
resp.raise_for_status()
|
|
140
|
-
return coerce_holidays(resp.json())
|
|
141
|
-
|
|
142
|
-
def historical_list(
|
|
143
|
-
self, distribution_code: str, energy_code: str
|
|
144
|
-
) -> list[RinListEntry]:
|
|
145
|
-
"""Fetch and coerce historical RIN list (deduplicated)."""
|
|
146
|
-
resp = self.get_historical_list(distribution_code, energy_code)
|
|
147
|
-
resp.raise_for_status()
|
|
148
|
-
return coerce_historical_list(resp.json())
|
|
149
|
-
|
|
150
|
-
def historical_data(
|
|
151
|
-
self, rin: str, start_date: str, end_date: str
|
|
152
|
-
) -> RateInfo:
|
|
128
|
+
def historical_data(self, rin: str, start_date: str, end_date: str) -> RateInfo:
|
|
153
129
|
"""Fetch and coerce historical rate data."""
|
|
154
130
|
resp = self.get_historical_data(rin, start_date, end_date)
|
|
155
131
|
resp.raise_for_status()
|
|
@@ -160,9 +136,12 @@ class MIDASClient:
|
|
|
160
136
|
@staticmethod
|
|
161
137
|
def ghg(rate: RateInfo) -> bool:
|
|
162
138
|
"""True if rate-info represents a GHG signal."""
|
|
163
|
-
if rate.type
|
|
139
|
+
if rate.type in (RateType.GHG, RateType.MOER):
|
|
164
140
|
return True
|
|
165
|
-
if rate.values and rate.values[0].unit
|
|
141
|
+
if rate.values and rate.values[0].unit in (
|
|
142
|
+
Unit.G_CO2_PER_KWH,
|
|
143
|
+
Unit.KG_CO2_PER_KWH,
|
|
144
|
+
):
|
|
166
145
|
return True
|
|
167
146
|
return False
|
|
168
147
|
|
|
@@ -180,9 +159,7 @@ class MIDASClient:
|
|
|
180
159
|
"""True if the Flex Alert indicates an active alert (any non-zero value)."""
|
|
181
160
|
if not MIDASClient.flex_alert(rate):
|
|
182
161
|
return False
|
|
183
|
-
return any(
|
|
184
|
-
v.value is not None and v.value > 0 for v in rate.values
|
|
185
|
-
)
|
|
162
|
+
return any(v.value is not None and v.value > 0 for v in rate.values)
|
|
186
163
|
|
|
187
164
|
|
|
188
165
|
def create_client(
|
|
@@ -203,3 +180,16 @@ def create_auto_client(
|
|
|
203
180
|
"""Create a MIDAS client with auto-refreshing token."""
|
|
204
181
|
auth = AutoTokenAuth(username, password, url)
|
|
205
182
|
return MIDASClient(base_url=url, auth=auth)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_anonymous_client(url: str = API_URL) -> MIDASClient:
|
|
186
|
+
"""Create an unauthenticated client for v2.0 GET endpoints (no token).
|
|
187
|
+
|
|
188
|
+
This is the default, supported access mode for this read-only consumer
|
|
189
|
+
library: v2.0 makes all public GET endpoints (rate values, RIN list, lookup
|
|
190
|
+
tables, historical data) unauthenticated, so no token is acquired and no
|
|
191
|
+
``Authorization`` header is sent. ``create_client`` / ``create_auto_client``
|
|
192
|
+
exist only for utilities that *upload* rate data to the CEC (POST); that
|
|
193
|
+
path requires CEC-issued utility credentials and is not exercised here.
|
|
194
|
+
"""
|
|
195
|
+
return MIDASClient(base_url=url)
|
midas/entities/__init__.py
CHANGED
|
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from midas.entities.models import (
|
|
8
|
-
Holiday,
|
|
9
8
|
LookupEntry,
|
|
9
|
+
LookupTableResponse,
|
|
10
10
|
RateInfo,
|
|
11
11
|
RinListEntry,
|
|
12
|
+
RinListResponse,
|
|
12
13
|
ValueData,
|
|
13
14
|
)
|
|
14
15
|
|
|
@@ -18,42 +19,34 @@ def coerce_rate_info(raw: dict[str, Any]) -> RateInfo:
|
|
|
18
19
|
return RateInfo.from_raw(raw)
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def coerce_rin_list(raw:
|
|
22
|
-
"""Coerce a
|
|
23
|
-
return [RinListEntry.from_raw(entry) for entry in raw]
|
|
22
|
+
def coerce_rin_list(raw: dict[str, Any], signal_type: int = 0) -> list[RinListEntry]:
|
|
23
|
+
"""Coerce a v2.0 keyed RIN-list response into a list of RinListEntry models.
|
|
24
24
|
|
|
25
|
+
v2.0 wraps the entry array under one of Rates/GHGEmissions/FlexAlerts/All,
|
|
26
|
+
keyed by ``signal_type``; this peels that key into a flat list.
|
|
27
|
+
"""
|
|
28
|
+
return RinListResponse.from_raw(raw, signal_type).entries
|
|
25
29
|
|
|
26
|
-
def coerce_holidays(raw: list[dict[str, Any]]) -> list[Holiday]:
|
|
27
|
-
"""Coerce a raw holidays response into a list of Holiday models."""
|
|
28
|
-
return [Holiday.from_raw(entry) for entry in raw]
|
|
29
30
|
|
|
31
|
+
def coerce_lookup_table(
|
|
32
|
+
raw: dict[str, Any] | list[dict[str, Any]],
|
|
33
|
+
) -> list[LookupEntry]:
|
|
34
|
+
"""Coerce a v2.0 lookup-table response into a list of LookupEntry models.
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def coerce_historical_list(raw: list[dict[str, Any]]) -> list[RinListEntry]:
|
|
37
|
-
"""Coerce a raw historical list response, deduplicating by RIN ID."""
|
|
38
|
-
seen: set[str] = set()
|
|
39
|
-
result: list[RinListEntry] = []
|
|
40
|
-
for entry in raw:
|
|
41
|
-
rid = entry["RateID"]
|
|
42
|
-
if rid not in seen:
|
|
43
|
-
seen.add(rid)
|
|
44
|
-
result.append(RinListEntry.from_raw(entry))
|
|
45
|
-
return result
|
|
36
|
+
v2.0 wraps the rows in ``{table_name, data: [...]}``; this peels ``data``
|
|
37
|
+
(a bare list is also tolerated for legacy/defensive use).
|
|
38
|
+
"""
|
|
39
|
+
return LookupTableResponse.from_raw(raw).entries
|
|
46
40
|
|
|
47
41
|
|
|
48
42
|
__all__ = [
|
|
49
43
|
"coerce_rate_info",
|
|
50
44
|
"coerce_rin_list",
|
|
51
|
-
"coerce_holidays",
|
|
52
45
|
"coerce_lookup_table",
|
|
53
|
-
"coerce_historical_list",
|
|
54
|
-
"Holiday",
|
|
55
46
|
"LookupEntry",
|
|
47
|
+
"LookupTableResponse",
|
|
56
48
|
"RateInfo",
|
|
57
49
|
"RinListEntry",
|
|
50
|
+
"RinListResponse",
|
|
58
51
|
"ValueData",
|
|
59
52
|
]
|
midas/entities/models.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import datetime
|
|
6
5
|
from decimal import Decimal
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
@@ -10,6 +9,11 @@ import pendulum
|
|
|
10
9
|
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
|
11
10
|
|
|
12
11
|
from midas.enums import DayType, RateType, SignalType, Unit
|
|
12
|
+
from midas.time import (
|
|
13
|
+
PendulumDateTime,
|
|
14
|
+
parse_instant,
|
|
15
|
+
parse_value_moment,
|
|
16
|
+
)
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
class MIDASBase(BaseModel):
|
|
@@ -27,38 +31,6 @@ class MIDASBase(BaseModel):
|
|
|
27
31
|
raise NotImplementedError("Subclasses must implement from_raw")
|
|
28
32
|
|
|
29
33
|
|
|
30
|
-
def _parse_date(s: str | None) -> datetime.date | None:
|
|
31
|
-
"""Parse an ISO date string (YYYY-MM-DD) or extract date from datetime string."""
|
|
32
|
-
if not s or not isinstance(s, str):
|
|
33
|
-
return None
|
|
34
|
-
# Handle datetime strings like "2023-12-25T00:00:00" — take date part
|
|
35
|
-
date_part = s[:10] if len(s) >= 10 else s
|
|
36
|
-
parts = date_part.split("-")
|
|
37
|
-
return datetime.date(int(parts[0]), int(parts[1]), int(parts[2]))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _parse_datetime(s: str | None) -> pendulum.DateTime | None:
|
|
41
|
-
"""Parse an ISO datetime string, treating naive datetimes as UTC."""
|
|
42
|
-
if not s or not isinstance(s, str):
|
|
43
|
-
return None
|
|
44
|
-
dt = pendulum.parse(s)
|
|
45
|
-
if dt.timezone is None or dt.timezone_name is None:
|
|
46
|
-
dt = dt.in_tz("UTC")
|
|
47
|
-
return dt
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _parse_time(s: str | None) -> datetime.time | None:
|
|
51
|
-
"""Parse a time string (HH:MM:SS or HH:MM)."""
|
|
52
|
-
if not s or not isinstance(s, str):
|
|
53
|
-
return None
|
|
54
|
-
parts = s.split(":")
|
|
55
|
-
if len(parts) == 2:
|
|
56
|
-
return datetime.time(int(parts[0]), int(parts[1]))
|
|
57
|
-
if len(parts) == 3:
|
|
58
|
-
return datetime.time(int(parts[0]), int(parts[1]), int(parts[2]))
|
|
59
|
-
return None
|
|
60
|
-
|
|
61
|
-
|
|
62
34
|
def _parse_decimal(n: int | float | None) -> Decimal | None:
|
|
63
35
|
"""Coerce a number to Decimal."""
|
|
64
36
|
if n is None:
|
|
@@ -103,29 +75,33 @@ def _parse_signal_type(s: str | None) -> SignalType | None:
|
|
|
103
75
|
|
|
104
76
|
|
|
105
77
|
class ValueData(MIDASBase):
|
|
106
|
-
"""A single time-series interval with a price or emissions value.
|
|
78
|
+
"""A single time-series interval with a price or emissions value.
|
|
79
|
+
|
|
80
|
+
The interval boundary is exposed as ``period`` — a ``(start, end)`` pair of
|
|
81
|
+
zone-aware ``pendulum.DateTime`` moments composed from the UTC wire date+time
|
|
82
|
+
(v2.0 delivers ``ValueInformation`` timestamps in UTC). Need a wall-clock
|
|
83
|
+
date or time? Derive it from a period endpoint (``period[0].in_tz(...)`` then
|
|
84
|
+
``.date()`` / ``.time()``). The original wire strings remain on ``_raw``.
|
|
85
|
+
"""
|
|
107
86
|
|
|
108
87
|
name: str
|
|
109
|
-
|
|
110
|
-
date_end: datetime.date | None = None
|
|
88
|
+
period: tuple[pendulum.DateTime, pendulum.DateTime] | None = None
|
|
111
89
|
day_start: DayType | None = None
|
|
112
90
|
day_end: DayType | None = None
|
|
113
|
-
time_start: datetime.time | None = None
|
|
114
|
-
time_end: datetime.time | None = None
|
|
115
91
|
value: Decimal | None = None
|
|
116
92
|
unit: Unit | str | None = None
|
|
117
93
|
|
|
118
94
|
@classmethod
|
|
119
95
|
def from_raw(cls, raw: dict[str, Any]) -> ValueData:
|
|
96
|
+
start = parse_value_moment(raw.get("DateStart"), raw.get("TimeStart"))
|
|
97
|
+
end = parse_value_moment(raw.get("DateEnd"), raw.get("TimeEnd"))
|
|
98
|
+
period = (start, end) if start is not None and end is not None else None
|
|
120
99
|
inst = cls(
|
|
121
100
|
name=raw["ValueName"],
|
|
122
|
-
|
|
123
|
-
date_end=_parse_date(raw.get("DateEnd")),
|
|
101
|
+
period=period,
|
|
124
102
|
day_start=_parse_day_type(raw.get("DayStart")),
|
|
125
103
|
day_end=_parse_day_type(raw.get("DayEnd")),
|
|
126
|
-
|
|
127
|
-
time_end=_parse_time(raw.get("TimeEnd")),
|
|
128
|
-
value=_parse_decimal(raw.get("value")),
|
|
104
|
+
value=_parse_decimal(raw.get("Value")),
|
|
129
105
|
unit=_parse_unit(raw.get("Unit")),
|
|
130
106
|
)
|
|
131
107
|
inst._raw = raw
|
|
@@ -135,8 +111,8 @@ class ValueData(MIDASBase):
|
|
|
135
111
|
class RateInfo(MIDASBase):
|
|
136
112
|
"""Rate information and associated time-series values for a single RIN."""
|
|
137
113
|
|
|
138
|
-
id: str
|
|
139
|
-
system_time:
|
|
114
|
+
id: str | None = None
|
|
115
|
+
system_time: PendulumDateTime = None
|
|
140
116
|
name: str | None = None
|
|
141
117
|
type: RateType | str | None = None
|
|
142
118
|
sector: str | None = None
|
|
@@ -145,7 +121,7 @@ class RateInfo(MIDASBase):
|
|
|
145
121
|
rate_plan_url: str | None = None
|
|
146
122
|
alt_name_1: str | None = None
|
|
147
123
|
alt_name_2: str | None = None
|
|
148
|
-
signup_close:
|
|
124
|
+
signup_close: PendulumDateTime = None
|
|
149
125
|
values: list[ValueData] = []
|
|
150
126
|
|
|
151
127
|
@classmethod
|
|
@@ -158,8 +134,8 @@ class RateInfo(MIDASBase):
|
|
|
158
134
|
values = [ValueData.from_raw(v) for v in vi] if vi else []
|
|
159
135
|
|
|
160
136
|
inst = cls(
|
|
161
|
-
id=raw
|
|
162
|
-
system_time=
|
|
137
|
+
id=raw.get("RateID"),
|
|
138
|
+
system_time=parse_instant(raw.get("SystemTime_UTC")),
|
|
163
139
|
name=raw.get("RateName"),
|
|
164
140
|
type=_parse_rate_type(raw.get("RateType")),
|
|
165
141
|
sector=raw.get("Sector"),
|
|
@@ -168,7 +144,7 @@ class RateInfo(MIDASBase):
|
|
|
168
144
|
rate_plan_url=raw.get("RatePlan_Url"),
|
|
169
145
|
alt_name_1=raw.get("AltRateName1"),
|
|
170
146
|
alt_name_2=raw.get("AltRateName2"),
|
|
171
|
-
signup_close=
|
|
147
|
+
signup_close=parse_instant(raw.get("SignupCloseDate")),
|
|
172
148
|
values=values,
|
|
173
149
|
)
|
|
174
150
|
inst._raw = raw
|
|
@@ -176,12 +152,12 @@ class RateInfo(MIDASBase):
|
|
|
176
152
|
|
|
177
153
|
|
|
178
154
|
class RinListEntry(MIDASBase):
|
|
179
|
-
"""A RIN catalog entry from the RIN
|
|
155
|
+
"""A RIN catalog entry from the RIN-list endpoint."""
|
|
180
156
|
|
|
181
157
|
id: str
|
|
182
158
|
signal_type: SignalType | None = None
|
|
183
159
|
description: str | None = None
|
|
184
|
-
last_updated:
|
|
160
|
+
last_updated: PendulumDateTime = None
|
|
185
161
|
|
|
186
162
|
@classmethod
|
|
187
163
|
def from_raw(cls, raw: dict[str, Any]) -> RinListEntry:
|
|
@@ -189,43 +165,94 @@ class RinListEntry(MIDASBase):
|
|
|
189
165
|
id=raw["RateID"],
|
|
190
166
|
signal_type=_parse_signal_type(raw.get("SignalType")),
|
|
191
167
|
description=raw.get("Description"),
|
|
192
|
-
|
|
168
|
+
# v2.0 UTC instant with a basic-format offset (e.g. "+0000"); the
|
|
169
|
+
# string is self-describing. Absent on Flex Alert entries → None.
|
|
170
|
+
last_updated=parse_instant(raw.get("LastUpdated")),
|
|
193
171
|
)
|
|
194
172
|
inst._raw = raw
|
|
195
173
|
return inst
|
|
196
174
|
|
|
197
175
|
|
|
198
|
-
class
|
|
199
|
-
"""
|
|
176
|
+
class RinListResponse(MIDASBase):
|
|
177
|
+
"""The v2.0 keyed RIN-list response (``GET /ValueData?SignalType=N``).
|
|
200
178
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
179
|
+
v1.0 returned a bare array; v2.0 wraps it in a single-keyed object. On the
|
|
180
|
+
live v2.0 API the wrapper key is **always** ``Rates``, regardless of the
|
|
181
|
+
requested ``SignalType`` (confirmed 2026-06-22 for SignalType 0/1/2/3 — the
|
|
182
|
+
``GHGEmissions``/``FlexAlerts``/``All`` keys implied by early design notes
|
|
183
|
+
do not appear on the wire). This model peels the single value without
|
|
184
|
+
switching on the key name; each entry's ``signal_type`` field still
|
|
185
|
+
identifies its signal class.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
signal_type: int = 0
|
|
189
|
+
entries: list[RinListEntry] = []
|
|
205
190
|
|
|
206
191
|
@classmethod
|
|
207
|
-
def from_raw(cls, raw: dict[str, Any]) ->
|
|
192
|
+
def from_raw(cls, raw: dict[str, Any], signal_type: int = 0) -> RinListResponse:
|
|
193
|
+
# Always "Rates" on the live API; fall back to the first list value
|
|
194
|
+
# present so an unexpected wrapper key still peels correctly.
|
|
195
|
+
arr = raw.get("Rates")
|
|
196
|
+
if arr is None:
|
|
197
|
+
arr = next((v for v in raw.values() if isinstance(v, list)), None)
|
|
208
198
|
inst = cls(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
date=_parse_date(raw.get("DateOfHoliday")),
|
|
212
|
-
description=raw.get("HolidayDescription"),
|
|
199
|
+
signal_type=signal_type,
|
|
200
|
+
entries=[RinListEntry.from_raw(e) for e in (arr or [])],
|
|
213
201
|
)
|
|
214
202
|
inst._raw = raw
|
|
215
203
|
return inst
|
|
216
204
|
|
|
217
205
|
|
|
218
206
|
class LookupEntry(MIDASBase):
|
|
219
|
-
"""A reference/lookup table entry.
|
|
207
|
+
"""A reference/lookup table entry.
|
|
208
|
+
|
|
209
|
+
All rows carry ``UploadCode`` and ``Description``; some tables add extra
|
|
210
|
+
columns (e.g. the ``Unit`` table's ``PayloadDescriptor`` and ``UnitType``),
|
|
211
|
+
surfaced here as optional fields. Any other columns remain on ``_raw``.
|
|
212
|
+
"""
|
|
220
213
|
|
|
221
214
|
code: str
|
|
222
215
|
description: str | None = None
|
|
216
|
+
payload_descriptor: str | None = None
|
|
217
|
+
unit_type: str | None = None
|
|
223
218
|
|
|
224
219
|
@classmethod
|
|
225
220
|
def from_raw(cls, raw: dict[str, Any]) -> LookupEntry:
|
|
226
221
|
inst = cls(
|
|
227
222
|
code=raw["UploadCode"],
|
|
228
223
|
description=raw.get("Description"),
|
|
224
|
+
payload_descriptor=raw.get("PayloadDescriptor"),
|
|
225
|
+
unit_type=raw.get("UnitType"),
|
|
229
226
|
)
|
|
230
227
|
inst._raw = raw
|
|
231
228
|
return inst
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class LookupTableResponse(MIDASBase):
|
|
232
|
+
"""The v2.0 keyed lookup-table response (``GET /ValueData?LookupTable=X``).
|
|
233
|
+
|
|
234
|
+
v1.0 returned a bare array; v2.0 wraps it in ``{table_name, data: [...]}``
|
|
235
|
+
(confirmed against the live API 2026-06-22). This model peels ``data`` into
|
|
236
|
+
a flat list of :class:`LookupEntry`.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
table_name: str | None = None
|
|
240
|
+
entries: list[LookupEntry] = []
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_raw(
|
|
244
|
+
cls, raw: dict[str, Any] | list[dict[str, Any]]
|
|
245
|
+
) -> LookupTableResponse:
|
|
246
|
+
# v2.0: keyed object. Tolerate a bare list (legacy / defensive).
|
|
247
|
+
if isinstance(raw, dict):
|
|
248
|
+
table_name = raw.get("table_name")
|
|
249
|
+
rows = raw.get("data") or []
|
|
250
|
+
else:
|
|
251
|
+
table_name = None
|
|
252
|
+
rows = raw or []
|
|
253
|
+
inst = cls(
|
|
254
|
+
table_name=table_name,
|
|
255
|
+
entries=[LookupEntry.from_raw(e) for e in rows],
|
|
256
|
+
)
|
|
257
|
+
inst._raw = raw if isinstance(raw, dict) else {"data": raw}
|
|
258
|
+
return inst
|
midas/enums.py
CHANGED
|
@@ -6,17 +6,46 @@ from enum import Enum
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class SignalType(str, Enum):
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
"""Per-entry ``SignalType`` label in v2.0 RIN-list responses.
|
|
10
|
+
|
|
11
|
+
v2.0 populates this field with long-form labels for every signal class
|
|
12
|
+
(v1.0 used ``"Rates"`` and left GHG/Flex Alert entries ``null``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
RATES = "Electricity Rates"
|
|
16
|
+
GHG_EMISSIONS = "Greenhouse Gas Emissions"
|
|
17
|
+
FLEX_ALERT = "California Independent System Operator Flex Alert"
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
class RateType(str, Enum):
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
"""``RateType`` wire value, which is **inconsistent across signal types**
|
|
22
|
+
in v2.0 (confirmed against the live API 2026-06-22):
|
|
23
|
+
|
|
24
|
+
* Electricity rates return the short ``Ratetype`` lookup UploadCode
|
|
25
|
+
(``TOU``, ``CPP``, ``RTP``, …) — *not* the long Description.
|
|
26
|
+
* SGIP GHG returns the long Description ``Greenhouse Gas emissions``.
|
|
27
|
+
* Flex Alert returns the long Description ``Flex Alert``.
|
|
28
|
+
|
|
29
|
+
The field is a lenient passthrough, so any unmodelled label coerces to a
|
|
30
|
+
plain string rather than raising.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Electricity rates — short Ratetype UploadCodes (v2.0 wire form).
|
|
34
|
+
TOU = "TOU"
|
|
35
|
+
CPP = "CPP"
|
|
36
|
+
RTP = "RTP"
|
|
37
|
+
VPP = "VPP"
|
|
38
|
+
DSR = "DSR"
|
|
39
|
+
V_D = "V-D"
|
|
40
|
+
C_D = "C-D"
|
|
41
|
+
R_D = "R-D"
|
|
42
|
+
T_D = "T-D"
|
|
43
|
+
# GHG and Flex Alert return long Descriptions, not short codes.
|
|
18
44
|
GHG = "Greenhouse Gas emissions"
|
|
19
45
|
FLEX_ALERT = "Flex Alert"
|
|
46
|
+
# v2.0 unified SGIP GHG signal (RIN segment-3 code MOER); retained for the
|
|
47
|
+
# Ratetype lookup UploadCode and any RIN whose RateType surfaces as "MOER".
|
|
48
|
+
MOER = "MOER"
|
|
20
49
|
|
|
21
50
|
|
|
22
51
|
class Unit(str, Enum):
|
|
@@ -24,6 +53,9 @@ class Unit(str, Enum):
|
|
|
24
53
|
DOLLAR_PER_KW = "$/kW"
|
|
25
54
|
EXPORT_DOLLAR_PER_KWH = "export $/kWh"
|
|
26
55
|
BACKUP_DOLLAR_PER_KWH = "backup $/kWh"
|
|
56
|
+
# v2.0 reports GHG emissions in grams (1000× the v1.0 kilogram value).
|
|
57
|
+
G_CO2_PER_KWH = "g/kWh CO2"
|
|
58
|
+
# Retained for pre-migration historical-archive reads.
|
|
27
59
|
KG_CO2_PER_KWH = "kg/kWh CO2"
|
|
28
60
|
DOLLAR_PER_KVARH = "$/kvarh"
|
|
29
61
|
EVENT = "Event"
|