python-midas 0.1.1__py3-none-any.whl → 1.0.1__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 +99 -69
- midas/enums.py +76 -6
- midas/time.py +101 -0
- python_midas-1.0.1.dist-info/METADATA +418 -0
- python_midas-1.0.1.dist-info/RECORD +12 -0
- {python_midas-0.1.1.dist-info → python_midas-1.0.1.dist-info}/WHEEL +1 -1
- python_midas-0.1.1.dist-info/METADATA +0 -404
- python_midas-0.1.1.dist-info/RECORD +0 -11
- {python_midas-0.1.1.dist-info → python_midas-1.0.1.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:
|
|
@@ -66,13 +38,10 @@ def _parse_decimal(n: int | float | None) -> Decimal | None:
|
|
|
66
38
|
return Decimal(str(n))
|
|
67
39
|
|
|
68
40
|
|
|
69
|
-
def _parse_day_type(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return DayType(s)
|
|
74
|
-
except ValueError:
|
|
75
|
-
return None
|
|
41
|
+
def _parse_day_type(v: object) -> DayType | None:
|
|
42
|
+
# v2.0 MOER/ALRT send an integer code (1=Mon..8=Holiday); v1.0/electricity
|
|
43
|
+
# send a weekday string. DayType.from_wire accepts both.
|
|
44
|
+
return DayType.from_wire(v)
|
|
76
45
|
|
|
77
46
|
|
|
78
47
|
def _parse_unit(s: str | None) -> Unit | str | None:
|
|
@@ -103,29 +72,33 @@ def _parse_signal_type(s: str | None) -> SignalType | None:
|
|
|
103
72
|
|
|
104
73
|
|
|
105
74
|
class ValueData(MIDASBase):
|
|
106
|
-
"""A single time-series interval with a price or emissions value.
|
|
75
|
+
"""A single time-series interval with a price or emissions value.
|
|
76
|
+
|
|
77
|
+
The interval boundary is exposed as ``period`` — a ``(start, end)`` pair of
|
|
78
|
+
zone-aware ``pendulum.DateTime`` moments composed from the UTC wire date+time
|
|
79
|
+
(v2.0 delivers ``ValueInformation`` timestamps in UTC). Need a wall-clock
|
|
80
|
+
date or time? Derive it from a period endpoint (``period[0].in_tz(...)`` then
|
|
81
|
+
``.date()`` / ``.time()``). The original wire strings remain on ``_raw``.
|
|
82
|
+
"""
|
|
107
83
|
|
|
108
84
|
name: str
|
|
109
|
-
|
|
110
|
-
date_end: datetime.date | None = None
|
|
85
|
+
period: tuple[pendulum.DateTime, pendulum.DateTime] | None = None
|
|
111
86
|
day_start: DayType | None = None
|
|
112
87
|
day_end: DayType | None = None
|
|
113
|
-
time_start: datetime.time | None = None
|
|
114
|
-
time_end: datetime.time | None = None
|
|
115
88
|
value: Decimal | None = None
|
|
116
89
|
unit: Unit | str | None = None
|
|
117
90
|
|
|
118
91
|
@classmethod
|
|
119
92
|
def from_raw(cls, raw: dict[str, Any]) -> ValueData:
|
|
93
|
+
start = parse_value_moment(raw.get("DateStart"), raw.get("TimeStart"))
|
|
94
|
+
end = parse_value_moment(raw.get("DateEnd"), raw.get("TimeEnd"))
|
|
95
|
+
period = (start, end) if start is not None and end is not None else None
|
|
120
96
|
inst = cls(
|
|
121
97
|
name=raw["ValueName"],
|
|
122
|
-
|
|
123
|
-
date_end=_parse_date(raw.get("DateEnd")),
|
|
98
|
+
period=period,
|
|
124
99
|
day_start=_parse_day_type(raw.get("DayStart")),
|
|
125
100
|
day_end=_parse_day_type(raw.get("DayEnd")),
|
|
126
|
-
|
|
127
|
-
time_end=_parse_time(raw.get("TimeEnd")),
|
|
128
|
-
value=_parse_decimal(raw.get("value")),
|
|
101
|
+
value=_parse_decimal(raw.get("Value")),
|
|
129
102
|
unit=_parse_unit(raw.get("Unit")),
|
|
130
103
|
)
|
|
131
104
|
inst._raw = raw
|
|
@@ -136,8 +109,10 @@ class RateInfo(MIDASBase):
|
|
|
136
109
|
"""Rate information and associated time-series values for a single RIN."""
|
|
137
110
|
|
|
138
111
|
id: str | None = None
|
|
139
|
-
system_time:
|
|
112
|
+
system_time: PendulumDateTime = None
|
|
140
113
|
name: str | None = None
|
|
114
|
+
signal_type: SignalType | None = None
|
|
115
|
+
description: str | None = None
|
|
141
116
|
type: RateType | str | None = None
|
|
142
117
|
sector: str | None = None
|
|
143
118
|
end_use: str | None = None
|
|
@@ -145,7 +120,7 @@ class RateInfo(MIDASBase):
|
|
|
145
120
|
rate_plan_url: str | None = None
|
|
146
121
|
alt_name_1: str | None = None
|
|
147
122
|
alt_name_2: str | None = None
|
|
148
|
-
signup_close:
|
|
123
|
+
signup_close: PendulumDateTime = None
|
|
149
124
|
values: list[ValueData] = []
|
|
150
125
|
|
|
151
126
|
@classmethod
|
|
@@ -159,8 +134,12 @@ class RateInfo(MIDASBase):
|
|
|
159
134
|
|
|
160
135
|
inst = cls(
|
|
161
136
|
id=raw.get("RateID"),
|
|
162
|
-
system_time=
|
|
137
|
+
system_time=parse_instant(raw.get("SystemTime_UTC")),
|
|
163
138
|
name=raw.get("RateName"),
|
|
139
|
+
# v2.0 rate-values responses carry the per-RIN SignalType label and
|
|
140
|
+
# Description at the top level (as in the RIN list).
|
|
141
|
+
signal_type=_parse_signal_type(raw.get("SignalType")),
|
|
142
|
+
description=raw.get("Description"),
|
|
164
143
|
type=_parse_rate_type(raw.get("RateType")),
|
|
165
144
|
sector=raw.get("Sector"),
|
|
166
145
|
end_use=raw.get("EndUse"),
|
|
@@ -168,7 +147,7 @@ class RateInfo(MIDASBase):
|
|
|
168
147
|
rate_plan_url=raw.get("RatePlan_Url"),
|
|
169
148
|
alt_name_1=raw.get("AltRateName1"),
|
|
170
149
|
alt_name_2=raw.get("AltRateName2"),
|
|
171
|
-
signup_close=
|
|
150
|
+
signup_close=parse_instant(raw.get("SignupCloseDate")),
|
|
172
151
|
values=values,
|
|
173
152
|
)
|
|
174
153
|
inst._raw = raw
|
|
@@ -176,12 +155,12 @@ class RateInfo(MIDASBase):
|
|
|
176
155
|
|
|
177
156
|
|
|
178
157
|
class RinListEntry(MIDASBase):
|
|
179
|
-
"""A RIN catalog entry from the RIN
|
|
158
|
+
"""A RIN catalog entry from the RIN-list endpoint."""
|
|
180
159
|
|
|
181
160
|
id: str
|
|
182
161
|
signal_type: SignalType | None = None
|
|
183
162
|
description: str | None = None
|
|
184
|
-
last_updated:
|
|
163
|
+
last_updated: PendulumDateTime = None
|
|
185
164
|
|
|
186
165
|
@classmethod
|
|
187
166
|
def from_raw(cls, raw: dict[str, Any]) -> RinListEntry:
|
|
@@ -189,43 +168,94 @@ class RinListEntry(MIDASBase):
|
|
|
189
168
|
id=raw["RateID"],
|
|
190
169
|
signal_type=_parse_signal_type(raw.get("SignalType")),
|
|
191
170
|
description=raw.get("Description"),
|
|
192
|
-
|
|
171
|
+
# v2.0 UTC instant with a basic-format offset (e.g. "+0000"); the
|
|
172
|
+
# string is self-describing. Absent on Flex Alert entries → None.
|
|
173
|
+
last_updated=parse_instant(raw.get("LastUpdated")),
|
|
193
174
|
)
|
|
194
175
|
inst._raw = raw
|
|
195
176
|
return inst
|
|
196
177
|
|
|
197
178
|
|
|
198
|
-
class
|
|
199
|
-
"""
|
|
179
|
+
class RinListResponse(MIDASBase):
|
|
180
|
+
"""The v2.0 keyed RIN-list response (``GET /ValueData?SignalType=N``).
|
|
200
181
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
182
|
+
v1.0 returned a bare array; v2.0 wraps it in a single-keyed object. On the
|
|
183
|
+
live v2.0 API the wrapper key is **always** ``Rates``, regardless of the
|
|
184
|
+
requested ``SignalType`` (confirmed 2026-06-22 for SignalType 0/1/2/3 — the
|
|
185
|
+
``GHGEmissions``/``FlexAlerts``/``All`` keys implied by early design notes
|
|
186
|
+
do not appear on the wire). This model peels the single value without
|
|
187
|
+
switching on the key name; each entry's ``signal_type`` field still
|
|
188
|
+
identifies its signal class.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
signal_type: int = 0
|
|
192
|
+
entries: list[RinListEntry] = []
|
|
205
193
|
|
|
206
194
|
@classmethod
|
|
207
|
-
def from_raw(cls, raw: dict[str, Any]) ->
|
|
195
|
+
def from_raw(cls, raw: dict[str, Any], signal_type: int = 0) -> RinListResponse:
|
|
196
|
+
# Always "Rates" on the live API; fall back to the first list value
|
|
197
|
+
# present so an unexpected wrapper key still peels correctly.
|
|
198
|
+
arr = raw.get("Rates")
|
|
199
|
+
if arr is None:
|
|
200
|
+
arr = next((v for v in raw.values() if isinstance(v, list)), None)
|
|
208
201
|
inst = cls(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
date=_parse_date(raw.get("DateOfHoliday")),
|
|
212
|
-
description=raw.get("HolidayDescription"),
|
|
202
|
+
signal_type=signal_type,
|
|
203
|
+
entries=[RinListEntry.from_raw(e) for e in (arr or [])],
|
|
213
204
|
)
|
|
214
205
|
inst._raw = raw
|
|
215
206
|
return inst
|
|
216
207
|
|
|
217
208
|
|
|
218
209
|
class LookupEntry(MIDASBase):
|
|
219
|
-
"""A reference/lookup table entry.
|
|
210
|
+
"""A reference/lookup table entry.
|
|
211
|
+
|
|
212
|
+
All rows carry ``UploadCode`` and ``Description``; some tables add extra
|
|
213
|
+
columns (e.g. the ``Unit`` table's ``PayloadDescriptor`` and ``UnitType``),
|
|
214
|
+
surfaced here as optional fields. Any other columns remain on ``_raw``.
|
|
215
|
+
"""
|
|
220
216
|
|
|
221
217
|
code: str
|
|
222
218
|
description: str | None = None
|
|
219
|
+
payload_descriptor: str | None = None
|
|
220
|
+
unit_type: str | None = None
|
|
223
221
|
|
|
224
222
|
@classmethod
|
|
225
223
|
def from_raw(cls, raw: dict[str, Any]) -> LookupEntry:
|
|
226
224
|
inst = cls(
|
|
227
225
|
code=raw["UploadCode"],
|
|
228
226
|
description=raw.get("Description"),
|
|
227
|
+
payload_descriptor=raw.get("PayloadDescriptor"),
|
|
228
|
+
unit_type=raw.get("UnitType"),
|
|
229
229
|
)
|
|
230
230
|
inst._raw = raw
|
|
231
231
|
return inst
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class LookupTableResponse(MIDASBase):
|
|
235
|
+
"""The v2.0 keyed lookup-table response (``GET /ValueData?LookupTable=X``).
|
|
236
|
+
|
|
237
|
+
v1.0 returned a bare array; v2.0 wraps it in ``{table_name, data: [...]}``
|
|
238
|
+
(confirmed against the live API 2026-06-22). This model peels ``data`` into
|
|
239
|
+
a flat list of :class:`LookupEntry`.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
table_name: str | None = None
|
|
243
|
+
entries: list[LookupEntry] = []
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_raw(
|
|
247
|
+
cls, raw: dict[str, Any] | list[dict[str, Any]]
|
|
248
|
+
) -> LookupTableResponse:
|
|
249
|
+
# v2.0: keyed object. Tolerate a bare list (legacy / defensive).
|
|
250
|
+
if isinstance(raw, dict):
|
|
251
|
+
table_name = raw.get("table_name")
|
|
252
|
+
rows = raw.get("data") or []
|
|
253
|
+
else:
|
|
254
|
+
table_name = None
|
|
255
|
+
rows = raw or []
|
|
256
|
+
inst = cls(
|
|
257
|
+
table_name=table_name,
|
|
258
|
+
entries=[LookupEntry.from_raw(e) for e in rows],
|
|
259
|
+
)
|
|
260
|
+
inst._raw = raw if isinstance(raw, dict) else {"data": raw}
|
|
261
|
+
return inst
|