python-midas 0.1.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 +61 -0
- midas/auth.py +109 -0
- midas/client.py +205 -0
- midas/entities/__init__.py +59 -0
- midas/entities/models.py +231 -0
- midas/enums.py +41 -0
- midas/py.typed +0 -0
- python_midas-0.1.0.dist-info/METADATA +404 -0
- python_midas-0.1.0.dist-info/RECORD +11 -0
- python_midas-0.1.0.dist-info/WHEEL +4 -0
- python_midas-0.1.0.dist-info/licenses/LICENSE +21 -0
midas/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""midas — Python client library for the California Energy Commission MIDAS API."""
|
|
2
|
+
|
|
3
|
+
from midas.auth import AutoTokenAuth, BasicAuth, BearerAuth, get_token, token_expired
|
|
4
|
+
from midas.client import (
|
|
5
|
+
API_URL,
|
|
6
|
+
MIDASClient,
|
|
7
|
+
body,
|
|
8
|
+
create_auto_client,
|
|
9
|
+
create_client,
|
|
10
|
+
success,
|
|
11
|
+
)
|
|
12
|
+
from midas.entities import (
|
|
13
|
+
coerce_historical_list,
|
|
14
|
+
coerce_holidays,
|
|
15
|
+
coerce_lookup_table,
|
|
16
|
+
coerce_rate_info,
|
|
17
|
+
coerce_rin_list,
|
|
18
|
+
)
|
|
19
|
+
from midas.entities.models import (
|
|
20
|
+
Holiday,
|
|
21
|
+
LookupEntry,
|
|
22
|
+
MIDASBase,
|
|
23
|
+
RateInfo,
|
|
24
|
+
RinListEntry,
|
|
25
|
+
ValueData,
|
|
26
|
+
)
|
|
27
|
+
from midas.enums import DayType, RateType, SignalType, Unit
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Client
|
|
31
|
+
"MIDASClient",
|
|
32
|
+
"create_client",
|
|
33
|
+
"create_auto_client",
|
|
34
|
+
"success",
|
|
35
|
+
"body",
|
|
36
|
+
"API_URL",
|
|
37
|
+
# Auth
|
|
38
|
+
"BearerAuth",
|
|
39
|
+
"BasicAuth",
|
|
40
|
+
"AutoTokenAuth",
|
|
41
|
+
"get_token",
|
|
42
|
+
"token_expired",
|
|
43
|
+
# Entity coercion
|
|
44
|
+
"coerce_rate_info",
|
|
45
|
+
"coerce_rin_list",
|
|
46
|
+
"coerce_holidays",
|
|
47
|
+
"coerce_lookup_table",
|
|
48
|
+
"coerce_historical_list",
|
|
49
|
+
# Entity models
|
|
50
|
+
"MIDASBase",
|
|
51
|
+
"RateInfo",
|
|
52
|
+
"ValueData",
|
|
53
|
+
"RinListEntry",
|
|
54
|
+
"Holiday",
|
|
55
|
+
"LookupEntry",
|
|
56
|
+
# Enums
|
|
57
|
+
"SignalType",
|
|
58
|
+
"RateType",
|
|
59
|
+
"Unit",
|
|
60
|
+
"DayType",
|
|
61
|
+
]
|
midas/auth.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Authentication utilities for the MIDAS API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pendulum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BearerAuth(httpx.Auth):
|
|
12
|
+
"""httpx Auth subclass that injects a Bearer token."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, token: str) -> None:
|
|
15
|
+
self.token = token
|
|
16
|
+
|
|
17
|
+
def auth_flow(
|
|
18
|
+
self, request: httpx.Request
|
|
19
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
20
|
+
request.headers["Authorization"] = f"Bearer {self.token}"
|
|
21
|
+
yield request
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BasicAuth(httpx.Auth):
|
|
25
|
+
"""httpx Auth subclass that injects HTTP Basic auth."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, username: str, password: str) -> None:
|
|
28
|
+
import base64
|
|
29
|
+
|
|
30
|
+
credentials = f"{username}:{password}"
|
|
31
|
+
encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii")
|
|
32
|
+
self._header_value = f"Basic {encoded}"
|
|
33
|
+
|
|
34
|
+
def auth_flow(
|
|
35
|
+
self, request: httpx.Request
|
|
36
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
37
|
+
request.headers["Authorization"] = self._header_value
|
|
38
|
+
yield request
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_token(
|
|
42
|
+
username: str,
|
|
43
|
+
password: str,
|
|
44
|
+
url: str = "https://midasapi.energy.ca.gov/api",
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Authenticate with MIDAS using HTTP Basic auth and return token info.
|
|
47
|
+
|
|
48
|
+
The token is returned in the `Token` response header and is valid for
|
|
49
|
+
10 minutes. Returns a dict with token, acquired_at, and expires_at.
|
|
50
|
+
|
|
51
|
+
Raises httpx.HTTPStatusError on failure.
|
|
52
|
+
"""
|
|
53
|
+
import base64
|
|
54
|
+
|
|
55
|
+
credentials = f"{username}:{password}"
|
|
56
|
+
encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii")
|
|
57
|
+
|
|
58
|
+
timeout = httpx.Timeout(30.0, connect=30.0)
|
|
59
|
+
with httpx.Client(timeout=timeout) as client:
|
|
60
|
+
resp = client.get(
|
|
61
|
+
f"{url}/Token",
|
|
62
|
+
headers={"Authorization": f"Basic {encoded}"},
|
|
63
|
+
)
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
|
|
66
|
+
token = resp.headers.get("token") or resp.headers.get("Token")
|
|
67
|
+
now = pendulum.now("UTC")
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"token": token,
|
|
71
|
+
"acquired_at": now,
|
|
72
|
+
"expires_at": now.add(seconds=600),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def token_expired(
|
|
77
|
+
token_info: dict[str, Any], buffer_seconds: int = 30
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""True if a token-info dict is expired or will expire within buffer_seconds."""
|
|
80
|
+
expires_at = token_info.get("expires_at")
|
|
81
|
+
if expires_at is None:
|
|
82
|
+
return True
|
|
83
|
+
now = pendulum.now("UTC")
|
|
84
|
+
return now >= expires_at.subtract(seconds=buffer_seconds)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AutoTokenAuth(httpx.Auth):
|
|
88
|
+
"""httpx Auth that auto-refreshes the MIDAS bearer token when expired."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
username: str,
|
|
93
|
+
password: str,
|
|
94
|
+
url: str = "https://midasapi.energy.ca.gov/api",
|
|
95
|
+
buffer_seconds: int = 30,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.username = username
|
|
98
|
+
self.password = password
|
|
99
|
+
self.url = url
|
|
100
|
+
self.buffer_seconds = buffer_seconds
|
|
101
|
+
self.token_info: dict[str, Any] = get_token(username, password, url)
|
|
102
|
+
|
|
103
|
+
def auth_flow(
|
|
104
|
+
self, request: httpx.Request
|
|
105
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
106
|
+
if token_expired(self.token_info, self.buffer_seconds):
|
|
107
|
+
self.token_info = get_token(self.username, self.password, self.url)
|
|
108
|
+
request.headers["Authorization"] = f"Bearer {self.token_info['token']}"
|
|
109
|
+
yield request
|
midas/client.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""MIDAS API client — HTTP with entity coercion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from midas.auth import AutoTokenAuth, BearerAuth, get_token
|
|
10
|
+
from midas.entities import (
|
|
11
|
+
coerce_historical_list,
|
|
12
|
+
coerce_holidays,
|
|
13
|
+
coerce_lookup_table,
|
|
14
|
+
coerce_rate_info,
|
|
15
|
+
coerce_rin_list,
|
|
16
|
+
)
|
|
17
|
+
from midas.entities.models import (
|
|
18
|
+
Holiday,
|
|
19
|
+
LookupEntry,
|
|
20
|
+
RateInfo,
|
|
21
|
+
RinListEntry,
|
|
22
|
+
)
|
|
23
|
+
from midas.enums import RateType, Unit
|
|
24
|
+
|
|
25
|
+
API_URL = "https://midasapi.energy.ca.gov/api"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def success(resp: httpx.Response) -> bool:
|
|
29
|
+
"""Check if an HTTP response indicates success (2xx)."""
|
|
30
|
+
return 200 <= resp.status_code < 300
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def body(resp: httpx.Response) -> Any:
|
|
34
|
+
"""Extract JSON body from a response."""
|
|
35
|
+
return resp.json()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MIDASClient:
|
|
39
|
+
"""MIDAS API HTTP client with raw and coerced methods."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
base_url: str = API_URL,
|
|
44
|
+
token: str | None = None,
|
|
45
|
+
auth: httpx.Auth | None = None,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
_timeout = httpx.Timeout(timeout, connect=timeout)
|
|
50
|
+
if auth:
|
|
51
|
+
self._http = httpx.Client(base_url=self.base_url, auth=auth, timeout=_timeout)
|
|
52
|
+
elif token:
|
|
53
|
+
self._http = httpx.Client(
|
|
54
|
+
base_url=self.base_url, auth=BearerAuth(token), timeout=_timeout
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=_timeout)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
"""Close the underlying HTTP client."""
|
|
61
|
+
self._http.close()
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> MIDASClient:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *args: Any) -> None:
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
# -- Raw methods (return httpx.Response) --
|
|
70
|
+
|
|
71
|
+
def get_rin_list(self, signal_type: int = 0) -> httpx.Response:
|
|
72
|
+
"""Fetch list of available RINs by signal type (0=All, 1=Rates, 2=GHG, 3=Flex Alert)."""
|
|
73
|
+
return self._http.get("/ValueData", params={"SignalType": signal_type})
|
|
74
|
+
|
|
75
|
+
def get_rate_values(
|
|
76
|
+
self, rin: str, query_type: str = "alldata"
|
|
77
|
+
) -> httpx.Response:
|
|
78
|
+
"""Fetch rate/price values for a specific RIN."""
|
|
79
|
+
return self._http.get(
|
|
80
|
+
"/ValueData", params={"ID": rin, "QueryType": query_type}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def get_lookup_table(self, table_name: str) -> httpx.Response:
|
|
84
|
+
"""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
|
+
)
|
|
104
|
+
|
|
105
|
+
def get_historical_data(
|
|
106
|
+
self, rin: str, start_date: str, end_date: str
|
|
107
|
+
) -> httpx.Response:
|
|
108
|
+
"""Fetch archived rate data for a RIN within a date range."""
|
|
109
|
+
return self._http.get(
|
|
110
|
+
"/HistoricalData",
|
|
111
|
+
params={"id": rin, "startdate": start_date, "enddate": end_date},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# -- Coerced methods (return typed models) --
|
|
115
|
+
|
|
116
|
+
def rin_list(self, signal_type: int = 0) -> list[RinListEntry]:
|
|
117
|
+
"""Fetch and coerce RIN list."""
|
|
118
|
+
resp = self.get_rin_list(signal_type)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
return coerce_rin_list(resp.json())
|
|
121
|
+
|
|
122
|
+
def rate_values(
|
|
123
|
+
self, rin: str, query_type: str = "alldata"
|
|
124
|
+
) -> RateInfo:
|
|
125
|
+
"""Fetch and coerce rate values for a specific RIN."""
|
|
126
|
+
resp = self.get_rate_values(rin, query_type)
|
|
127
|
+
resp.raise_for_status()
|
|
128
|
+
return coerce_rate_info(resp.json())
|
|
129
|
+
|
|
130
|
+
def lookup_table(self, table_name: str) -> list[LookupEntry]:
|
|
131
|
+
"""Fetch and coerce a lookup table."""
|
|
132
|
+
resp = self.get_lookup_table(table_name)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
return coerce_lookup_table(resp.json())
|
|
135
|
+
|
|
136
|
+
def holidays(self) -> list[Holiday]:
|
|
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:
|
|
153
|
+
"""Fetch and coerce historical rate data."""
|
|
154
|
+
resp = self.get_historical_data(rin, start_date, end_date)
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
return coerce_rate_info(resp.json())
|
|
157
|
+
|
|
158
|
+
# -- Signal type helpers --
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def ghg(rate: RateInfo) -> bool:
|
|
162
|
+
"""True if rate-info represents a GHG signal."""
|
|
163
|
+
if rate.type == RateType.GHG:
|
|
164
|
+
return True
|
|
165
|
+
if rate.values and rate.values[0].unit == Unit.KG_CO2_PER_KWH:
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def flex_alert(rate: RateInfo) -> bool:
|
|
171
|
+
"""True if rate-info represents a Flex Alert signal."""
|
|
172
|
+
if rate.type == RateType.FLEX_ALERT:
|
|
173
|
+
return True
|
|
174
|
+
if rate.values and rate.values[0].unit == Unit.EVENT:
|
|
175
|
+
return True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def flex_alert_active(rate: RateInfo) -> bool:
|
|
180
|
+
"""True if the Flex Alert indicates an active alert (any non-zero value)."""
|
|
181
|
+
if not MIDASClient.flex_alert(rate):
|
|
182
|
+
return False
|
|
183
|
+
return any(
|
|
184
|
+
v.value is not None and v.value > 0 for v in rate.values
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def create_client(
|
|
189
|
+
username: str,
|
|
190
|
+
password: str,
|
|
191
|
+
url: str = API_URL,
|
|
192
|
+
) -> MIDASClient:
|
|
193
|
+
"""Create a MIDAS client with a manually-acquired token."""
|
|
194
|
+
token_info = get_token(username, password, url)
|
|
195
|
+
return MIDASClient(base_url=url, token=token_info["token"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def create_auto_client(
|
|
199
|
+
username: str,
|
|
200
|
+
password: str,
|
|
201
|
+
url: str = API_URL,
|
|
202
|
+
) -> MIDASClient:
|
|
203
|
+
"""Create a MIDAS client with auto-refreshing token."""
|
|
204
|
+
auth = AutoTokenAuth(username, password, url)
|
|
205
|
+
return MIDASClient(base_url=url, auth=auth)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Entity coercion dispatch for MIDAS API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from midas.entities.models import (
|
|
8
|
+
Holiday,
|
|
9
|
+
LookupEntry,
|
|
10
|
+
RateInfo,
|
|
11
|
+
RinListEntry,
|
|
12
|
+
ValueData,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def coerce_rate_info(raw: dict[str, Any]) -> RateInfo:
|
|
17
|
+
"""Coerce a raw rate info response dict into a RateInfo model."""
|
|
18
|
+
return RateInfo.from_raw(raw)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def coerce_rin_list(raw: list[dict[str, Any]]) -> list[RinListEntry]:
|
|
22
|
+
"""Coerce a raw RIN list response into a list of RinListEntry models."""
|
|
23
|
+
return [RinListEntry.from_raw(entry) for entry in raw]
|
|
24
|
+
|
|
25
|
+
|
|
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(raw: list[dict[str, Any]]) -> list[LookupEntry]:
|
|
32
|
+
"""Coerce a raw lookup table response into a list of LookupEntry models."""
|
|
33
|
+
return [LookupEntry.from_raw(entry) for entry in raw]
|
|
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
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"coerce_rate_info",
|
|
50
|
+
"coerce_rin_list",
|
|
51
|
+
"coerce_holidays",
|
|
52
|
+
"coerce_lookup_table",
|
|
53
|
+
"coerce_historical_list",
|
|
54
|
+
"Holiday",
|
|
55
|
+
"LookupEntry",
|
|
56
|
+
"RateInfo",
|
|
57
|
+
"RinListEntry",
|
|
58
|
+
"ValueData",
|
|
59
|
+
]
|
midas/entities/models.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Coerced Pydantic models for MIDAS API entities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pendulum
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
|
11
|
+
|
|
12
|
+
from midas.enums import DayType, RateType, SignalType, Unit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MIDASBase(BaseModel):
|
|
16
|
+
"""Base model for all coerced MIDAS entities.
|
|
17
|
+
|
|
18
|
+
Carries the original raw dict as a private attribute.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
22
|
+
|
|
23
|
+
_raw: dict[str, Any] = PrivateAttr(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_raw(cls, raw: dict[str, Any]) -> MIDASBase:
|
|
27
|
+
raise NotImplementedError("Subclasses must implement from_raw")
|
|
28
|
+
|
|
29
|
+
|
|
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
|
+
def _parse_decimal(n: int | float | None) -> Decimal | None:
|
|
63
|
+
"""Coerce a number to Decimal."""
|
|
64
|
+
if n is None:
|
|
65
|
+
return None
|
|
66
|
+
return Decimal(str(n))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_day_type(s: str | None) -> DayType | None:
|
|
70
|
+
if not s:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return DayType(s)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_unit(s: str | None) -> Unit | str | None:
|
|
79
|
+
if not s:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return Unit(s)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return s
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_rate_type(s: str | None) -> RateType | str | None:
|
|
88
|
+
if not s:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return RateType(s)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return s
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_signal_type(s: str | None) -> SignalType | None:
|
|
97
|
+
if not s:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return SignalType(s)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ValueData(MIDASBase):
|
|
106
|
+
"""A single time-series interval with a price or emissions value."""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
date_start: datetime.date | None = None
|
|
110
|
+
date_end: datetime.date | None = None
|
|
111
|
+
day_start: DayType | None = None
|
|
112
|
+
day_end: DayType | None = None
|
|
113
|
+
time_start: datetime.time | None = None
|
|
114
|
+
time_end: datetime.time | None = None
|
|
115
|
+
value: Decimal | None = None
|
|
116
|
+
unit: Unit | str | None = None
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_raw(cls, raw: dict[str, Any]) -> ValueData:
|
|
120
|
+
inst = cls(
|
|
121
|
+
name=raw["ValueName"],
|
|
122
|
+
date_start=_parse_date(raw.get("DateStart")),
|
|
123
|
+
date_end=_parse_date(raw.get("DateEnd")),
|
|
124
|
+
day_start=_parse_day_type(raw.get("DayStart")),
|
|
125
|
+
day_end=_parse_day_type(raw.get("DayEnd")),
|
|
126
|
+
time_start=_parse_time(raw.get("TimeStart")),
|
|
127
|
+
time_end=_parse_time(raw.get("TimeEnd")),
|
|
128
|
+
value=_parse_decimal(raw.get("value")),
|
|
129
|
+
unit=_parse_unit(raw.get("Unit")),
|
|
130
|
+
)
|
|
131
|
+
inst._raw = raw
|
|
132
|
+
return inst
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RateInfo(MIDASBase):
|
|
136
|
+
"""Rate information and associated time-series values for a single RIN."""
|
|
137
|
+
|
|
138
|
+
id: str
|
|
139
|
+
system_time: pendulum.DateTime | None = None
|
|
140
|
+
name: str | None = None
|
|
141
|
+
type: RateType | str | None = None
|
|
142
|
+
sector: str | None = None
|
|
143
|
+
end_use: str | None = None
|
|
144
|
+
api_url: str | None = None
|
|
145
|
+
rate_plan_url: str | None = None
|
|
146
|
+
alt_name_1: str | None = None
|
|
147
|
+
alt_name_2: str | None = None
|
|
148
|
+
signup_close: pendulum.DateTime | None = None
|
|
149
|
+
values: list[ValueData] = []
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_raw(cls, raw: dict[str, Any]) -> RateInfo:
|
|
153
|
+
api_url = raw.get("API_Url")
|
|
154
|
+
if api_url == "None":
|
|
155
|
+
api_url = None
|
|
156
|
+
|
|
157
|
+
vi = raw.get("ValueInformation")
|
|
158
|
+
values = [ValueData.from_raw(v) for v in vi] if vi else []
|
|
159
|
+
|
|
160
|
+
inst = cls(
|
|
161
|
+
id=raw["RateID"],
|
|
162
|
+
system_time=_parse_datetime(raw.get("SystemTime_UTC")),
|
|
163
|
+
name=raw.get("RateName"),
|
|
164
|
+
type=_parse_rate_type(raw.get("RateType")),
|
|
165
|
+
sector=raw.get("Sector"),
|
|
166
|
+
end_use=raw.get("EndUse"),
|
|
167
|
+
api_url=api_url,
|
|
168
|
+
rate_plan_url=raw.get("RatePlan_Url"),
|
|
169
|
+
alt_name_1=raw.get("AltRateName1"),
|
|
170
|
+
alt_name_2=raw.get("AltRateName2"),
|
|
171
|
+
signup_close=_parse_datetime(raw.get("SignupCloseDate")),
|
|
172
|
+
values=values,
|
|
173
|
+
)
|
|
174
|
+
inst._raw = raw
|
|
175
|
+
return inst
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RinListEntry(MIDASBase):
|
|
179
|
+
"""A RIN catalog entry from the RIN list or historical list endpoints."""
|
|
180
|
+
|
|
181
|
+
id: str
|
|
182
|
+
signal_type: SignalType | None = None
|
|
183
|
+
description: str | None = None
|
|
184
|
+
last_updated: pendulum.DateTime | None = None
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_raw(cls, raw: dict[str, Any]) -> RinListEntry:
|
|
188
|
+
inst = cls(
|
|
189
|
+
id=raw["RateID"],
|
|
190
|
+
signal_type=_parse_signal_type(raw.get("SignalType")),
|
|
191
|
+
description=raw.get("Description"),
|
|
192
|
+
last_updated=_parse_datetime(raw.get("LastUpdated")),
|
|
193
|
+
)
|
|
194
|
+
inst._raw = raw
|
|
195
|
+
return inst
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Holiday(MIDASBase):
|
|
199
|
+
"""A utility holiday entry."""
|
|
200
|
+
|
|
201
|
+
energy_code: str
|
|
202
|
+
energy_name: str | None = None
|
|
203
|
+
date: datetime.date | None = None
|
|
204
|
+
description: str | None = None
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def from_raw(cls, raw: dict[str, Any]) -> Holiday:
|
|
208
|
+
inst = cls(
|
|
209
|
+
energy_code=raw["EnergyCode"],
|
|
210
|
+
energy_name=raw.get("EnergyDescription"),
|
|
211
|
+
date=_parse_date(raw.get("DateOfHoliday")),
|
|
212
|
+
description=raw.get("HolidayDescription"),
|
|
213
|
+
)
|
|
214
|
+
inst._raw = raw
|
|
215
|
+
return inst
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class LookupEntry(MIDASBase):
|
|
219
|
+
"""A reference/lookup table entry."""
|
|
220
|
+
|
|
221
|
+
code: str
|
|
222
|
+
description: str | None = None
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_raw(cls, raw: dict[str, Any]) -> LookupEntry:
|
|
226
|
+
inst = cls(
|
|
227
|
+
code=raw["UploadCode"],
|
|
228
|
+
description=raw.get("Description"),
|
|
229
|
+
)
|
|
230
|
+
inst._raw = raw
|
|
231
|
+
return inst
|
midas/enums.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""MIDAS domain enumerations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SignalType(str, Enum):
|
|
9
|
+
RATES = "Rates"
|
|
10
|
+
GHG = "GHG"
|
|
11
|
+
FLEX_ALERT = "Flex Alert"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateType(str, Enum):
|
|
15
|
+
TOU = "Time of use"
|
|
16
|
+
CPP = "Critical Peak Pricing"
|
|
17
|
+
RTP = "Real Time Pricing"
|
|
18
|
+
GHG = "Greenhouse Gas emissions"
|
|
19
|
+
FLEX_ALERT = "Flex Alert"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Unit(str, Enum):
|
|
23
|
+
DOLLAR_PER_KWH = "$/kWh"
|
|
24
|
+
DOLLAR_PER_KW = "$/kW"
|
|
25
|
+
EXPORT_DOLLAR_PER_KWH = "export $/kWh"
|
|
26
|
+
BACKUP_DOLLAR_PER_KWH = "backup $/kWh"
|
|
27
|
+
KG_CO2_PER_KWH = "kg/kWh CO2"
|
|
28
|
+
DOLLAR_PER_KVARH = "$/kvarh"
|
|
29
|
+
EVENT = "Event"
|
|
30
|
+
LEVEL = "Level"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DayType(str, Enum):
|
|
34
|
+
MONDAY = "Monday"
|
|
35
|
+
TUESDAY = "Tuesday"
|
|
36
|
+
WEDNESDAY = "Wednesday"
|
|
37
|
+
THURSDAY = "Thursday"
|
|
38
|
+
FRIDAY = "Friday"
|
|
39
|
+
SATURDAY = "Saturday"
|
|
40
|
+
SUNDAY = "Sunday"
|
|
41
|
+
HOLIDAY = "Holiday"
|
midas/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-midas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client library for the California Energy Commission MIDAS API
|
|
5
|
+
Project-URL: Homepage, https://grid-coordination.energy
|
|
6
|
+
Project-URL: Repository, https://github.com/grid-coordination/python-midas
|
|
7
|
+
Project-URL: Issues, https://github.com/grid-coordination/python-midas/issues
|
|
8
|
+
Author: Clark Communications Corporation
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: california,energy,ghg,grid,midas,rates
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pendulum>=3.0
|
|
24
|
+
Requires-Dist: pydantic>=2.5
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# python-midas
|
|
32
|
+
|
|
33
|
+
Python client library for the California Energy Commission [MIDAS](https://midasapi.energy.ca.gov/) (Market Informed Demand Automation Server) API.
|
|
34
|
+
|
|
35
|
+
MIDAS provides California energy rate data, greenhouse gas (GHG) emissions signals, and Flex Alert status. This library wraps the API with typed Pydantic models, automatic token management, and a two-layer data model that preserves raw API responses alongside coerced Python-native types.
|
|
36
|
+
|
|
37
|
+
Part of the [grid-coordination](https://github.com/grid-coordination) project family, alongside [clj-midas](https://github.com/grid-coordination/clj-midas) (Clojure client) and [midas-api-specs](https://github.com/grid-coordination/midas-api-specs) (OpenAPI specifications).
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install midas
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For development:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.10+.
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from midas import create_auto_client
|
|
57
|
+
|
|
58
|
+
client = create_auto_client("username", "password")
|
|
59
|
+
|
|
60
|
+
# List available Rate Identification Numbers (RINs)
|
|
61
|
+
rins = client.rin_list()
|
|
62
|
+
for rin in rins:
|
|
63
|
+
print(f"{rin.id} {rin.signal_type} {rin.description}")
|
|
64
|
+
|
|
65
|
+
# Get rate values for a specific RIN
|
|
66
|
+
rate = client.rate_values(rins[0].id)
|
|
67
|
+
print(f"{rate.name} ({rate.type})")
|
|
68
|
+
for v in rate.values:
|
|
69
|
+
print(f" {v.date_start} {v.time_start}-{v.time_end}: {v.value} {v.unit}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Authentication
|
|
73
|
+
|
|
74
|
+
MIDAS uses HTTP Basic authentication to acquire a short-lived bearer token (valid for 10 minutes). The library provides two client creation modes:
|
|
75
|
+
|
|
76
|
+
### Auto-refreshing client (recommended)
|
|
77
|
+
|
|
78
|
+
`create_auto_client` acquires a token on creation and transparently refreshes it before any request where the token is expired or about to expire (within a 30-second buffer):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from midas import create_auto_client
|
|
82
|
+
|
|
83
|
+
client = create_auto_client("username", "password")
|
|
84
|
+
# Token refreshes automatically — use the client for as long as you need
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Manual token client
|
|
88
|
+
|
|
89
|
+
`create_client` acquires a single token. You are responsible for creating a new client when it expires:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from midas import create_client
|
|
93
|
+
|
|
94
|
+
client = create_client("username", "password")
|
|
95
|
+
# Token is valid for ~10 minutes
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Low-level token management
|
|
99
|
+
|
|
100
|
+
For advanced use cases, you can manage tokens directly:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from midas import get_token, token_expired, MIDASClient
|
|
104
|
+
|
|
105
|
+
token_info = get_token("username", "password")
|
|
106
|
+
# token_info = {"token": "...", "acquired_at": DateTime, "expires_at": DateTime}
|
|
107
|
+
|
|
108
|
+
if token_expired(token_info):
|
|
109
|
+
token_info = get_token("username", "password")
|
|
110
|
+
|
|
111
|
+
client = MIDASClient(token=token_info["token"])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## API Coverage
|
|
115
|
+
|
|
116
|
+
The MIDAS API has a single multiplexed `/ValueData` endpoint that serves different response shapes depending on query parameters, plus separate endpoints for holidays and historical data. All six operations are covered:
|
|
117
|
+
|
|
118
|
+
### RIN List
|
|
119
|
+
|
|
120
|
+
List available Rate Identification Numbers, optionally filtered by signal type:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
all_rins = client.rin_list() # All signal types
|
|
124
|
+
rate_rins = client.rin_list(signal_type=1) # Rates only
|
|
125
|
+
ghg_rins = client.rin_list(signal_type=2) # GHG only
|
|
126
|
+
flex_rins = client.rin_list(signal_type=3) # Flex Alert only
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each `RinListEntry` has:
|
|
130
|
+
- `id` — the RIN string (e.g. `"USCA-PGPG-ETOU-0000"`)
|
|
131
|
+
- `signal_type` — `SignalType.RATES`, `SignalType.GHG`, or `SignalType.FLEX_ALERT`
|
|
132
|
+
- `description` — human-readable description
|
|
133
|
+
- `last_updated` — `pendulum.DateTime` of last data update
|
|
134
|
+
|
|
135
|
+
### Rate Values
|
|
136
|
+
|
|
137
|
+
Fetch current rate/price data for a specific RIN:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
rate = client.rate_values("USCA-TSTS-TTOU-TEST")
|
|
141
|
+
rate = client.rate_values("USCA-TSTS-TTOU-TEST", query_type="realtime")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The `RateInfo` model contains:
|
|
145
|
+
- `id` — the RIN
|
|
146
|
+
- `name` — rate name (e.g. `"CEC TEST24HTOU"`)
|
|
147
|
+
- `type` — `RateType` enum (`TOU`, `CPP`, `RTP`, `GHG`, `FLEX_ALERT`) or raw string
|
|
148
|
+
- `system_time` — server timestamp as `pendulum.DateTime`
|
|
149
|
+
- `sector`, `end_use` — customer classification
|
|
150
|
+
- `rate_plan_url`, `api_url` — external links (the API's `"None"` string is coerced to `None`)
|
|
151
|
+
- `signup_close` — rate signup deadline as `pendulum.DateTime`
|
|
152
|
+
- `values` — list of `ValueData` intervals
|
|
153
|
+
|
|
154
|
+
Each `ValueData` interval has:
|
|
155
|
+
- `name` — period description (e.g. `"winter off peak"`)
|
|
156
|
+
- `date_start`, `date_end` — `datetime.date`
|
|
157
|
+
- `day_start`, `day_end` — `DayType` enum (Monday through Sunday, plus Holiday)
|
|
158
|
+
- `time_start`, `time_end` — `datetime.time` (handles both `HH:MM:SS` and `HH:MM` formats)
|
|
159
|
+
- `value` — `Decimal` (preserves precision for financial data)
|
|
160
|
+
- `unit` — `Unit` enum (`$/kWh`, `$/kW`, `kg/kWh CO2`, `Event`, etc.)
|
|
161
|
+
|
|
162
|
+
### Lookup Tables
|
|
163
|
+
|
|
164
|
+
Fetch reference data tables:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
energies = client.lookup_table("Energy") # Energy providers
|
|
168
|
+
dists = client.lookup_table("Distribution") # Distribution companies
|
|
169
|
+
units = client.lookup_table("Unit") # Available units
|
|
170
|
+
sectors = client.lookup_table("Sector") # Customer sectors
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Available tables: `Country`, `Daytype`, `Distribution`, `Enduse`, `Energy`, `Location`, `Ratetype`, `Sector`, `State`, `Unit`.
|
|
174
|
+
|
|
175
|
+
Each `LookupEntry` has `code` and `description`.
|
|
176
|
+
|
|
177
|
+
### Holidays
|
|
178
|
+
|
|
179
|
+
Fetch utility-observed holidays:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
holidays = client.holidays()
|
|
183
|
+
for h in holidays:
|
|
184
|
+
print(f"{h.energy_name}: {h.date} — {h.description}")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Each `Holiday` has `energy_code`, `energy_name`, `date` (`datetime.date`), and `description`.
|
|
188
|
+
|
|
189
|
+
### Historical Data
|
|
190
|
+
|
|
191
|
+
Query archived rate data by provider and date range:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# List RINs with historical data for a provider pair
|
|
195
|
+
hist_rins = client.historical_list("PG", "PG") # PG&E distribution + energy
|
|
196
|
+
|
|
197
|
+
# Fetch archived data for a date range
|
|
198
|
+
hist = client.historical_data("USCA-PGPG-ETOU-0000", "2023-01-01", "2023-12-31")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The historical list is automatically deduplicated (the live API returns duplicate entries).
|
|
202
|
+
|
|
203
|
+
## Signal Type Helpers
|
|
204
|
+
|
|
205
|
+
Convenience methods for identifying signal types, matching the [clj-midas](https://github.com/grid-coordination/clj-midas) API:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
rate = client.rate_values("USCA-GHGH-SGHT-0000")
|
|
209
|
+
|
|
210
|
+
client.ghg(rate) # True if GHG signal (by RateType or Unit)
|
|
211
|
+
client.flex_alert(rate) # True if Flex Alert signal
|
|
212
|
+
client.flex_alert_active(rate) # True if Flex Alert with any non-zero value
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Two-Layer Data Model
|
|
216
|
+
|
|
217
|
+
Following the [python-oa3](https://github.com/grid-coordination/python-oa3) pattern, every entity provides two layers:
|
|
218
|
+
|
|
219
|
+
**Raw layer** — the original API JSON dict (PascalCase keys, string values), accessible via `_raw`:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
rate = client.rate_values("USCA-TSTS-TTOU-TEST")
|
|
223
|
+
rate._raw["RateID"] # "USCA-TSTS-TTOU-TEST"
|
|
224
|
+
rate._raw["ValueInformation"][0]["value"] # 0.1006
|
|
225
|
+
rate.values[0]._raw["Unit"] # "$/kWh"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Coerced layer** — typed Pydantic models with snake_case fields and native Python types:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
rate.id # "USCA-TSTS-TTOU-TEST"
|
|
232
|
+
rate.type # RateType.TOU
|
|
233
|
+
rate.system_time # pendulum.DateTime (UTC)
|
|
234
|
+
rate.values[0].value # Decimal("0.1006")
|
|
235
|
+
rate.values[0].unit # Unit.DOLLAR_PER_KWH
|
|
236
|
+
rate.values[0].day_start # DayType.MONDAY
|
|
237
|
+
rate.values[0].date_start # datetime.date(2023, 5, 1)
|
|
238
|
+
rate.values[0].time_start # datetime.time(7, 0, 0)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
This lets you work with clean, typed data while always being able to fall back to the exact API response when needed.
|
|
242
|
+
|
|
243
|
+
## Dual-Mode Client
|
|
244
|
+
|
|
245
|
+
Every endpoint is available in two forms:
|
|
246
|
+
|
|
247
|
+
**Raw methods** return `httpx.Response` for full HTTP control:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
resp = client.get_rin_list(signal_type=0)
|
|
251
|
+
resp.status_code # 200
|
|
252
|
+
resp.json() # raw JSON list
|
|
253
|
+
|
|
254
|
+
resp = client.get_rate_values("USCA-TSTS-TTOU-TEST", query_type="alldata")
|
|
255
|
+
resp = client.get_lookup_table("Energy")
|
|
256
|
+
resp = client.get_holidays()
|
|
257
|
+
resp = client.get_historical_list("PG", "PG")
|
|
258
|
+
resp = client.get_historical_data("USCA-PGPG-ETOU-0000", "2023-01-01", "2023-12-31")
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Coerced methods** return typed Pydantic models (call `raise_for_status()` internally):
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
rins = client.rin_list(signal_type=0) # list[RinListEntry]
|
|
265
|
+
rate = client.rate_values("USCA-TSTS-TTOU-TEST") # RateInfo
|
|
266
|
+
entries = client.lookup_table("Energy") # list[LookupEntry]
|
|
267
|
+
holidays = client.holidays() # list[Holiday]
|
|
268
|
+
rins = client.historical_list("PG", "PG") # list[RinListEntry]
|
|
269
|
+
rate = client.historical_data(rin, start, end) # RateInfo
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Coercion Functions
|
|
273
|
+
|
|
274
|
+
You can also coerce raw dicts directly, without going through the client:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from midas import coerce_rate_info, coerce_rin_list, coerce_holidays
|
|
278
|
+
|
|
279
|
+
rate = coerce_rate_info({"RateID": "...", "ValueInformation": [...]})
|
|
280
|
+
rins = coerce_rin_list([{"RateID": "...", "SignalType": "Rates", ...}])
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Available: `coerce_rate_info`, `coerce_rin_list`, `coerce_holidays`, `coerce_lookup_table`, `coerce_historical_list`.
|
|
284
|
+
|
|
285
|
+
## Enums
|
|
286
|
+
|
|
287
|
+
Domain values are represented as `str` enums, so they compare equal to their string values:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from midas import SignalType, RateType, Unit, DayType
|
|
291
|
+
|
|
292
|
+
SignalType.RATES # "Rates"
|
|
293
|
+
SignalType.GHG # "GHG"
|
|
294
|
+
SignalType.FLEX_ALERT # "Flex Alert"
|
|
295
|
+
|
|
296
|
+
RateType.TOU # "Time of use"
|
|
297
|
+
RateType.CPP # "Critical Peak Pricing"
|
|
298
|
+
RateType.RTP # "Real Time Pricing"
|
|
299
|
+
RateType.GHG # "Greenhouse Gas emissions"
|
|
300
|
+
RateType.FLEX_ALERT # "Flex Alert"
|
|
301
|
+
|
|
302
|
+
Unit.DOLLAR_PER_KWH # "$/kWh"
|
|
303
|
+
Unit.DOLLAR_PER_KW # "$/kW"
|
|
304
|
+
Unit.EXPORT_DOLLAR_PER_KWH # "export $/kWh"
|
|
305
|
+
Unit.BACKUP_DOLLAR_PER_KWH # "backup $/kWh"
|
|
306
|
+
Unit.KG_CO2_PER_KWH # "kg/kWh CO2"
|
|
307
|
+
Unit.DOLLAR_PER_KVARH # "$/kvarh"
|
|
308
|
+
Unit.EVENT # "Event"
|
|
309
|
+
Unit.LEVEL # "Level"
|
|
310
|
+
|
|
311
|
+
DayType.MONDAY # "Monday"
|
|
312
|
+
# ... through SUNDAY, plus:
|
|
313
|
+
DayType.HOLIDAY # "Holiday"
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Type Coercion Details
|
|
317
|
+
|
|
318
|
+
The coercion layer applies the following transformations:
|
|
319
|
+
|
|
320
|
+
| API type | Python type | Notes |
|
|
321
|
+
|----------|-------------|-------|
|
|
322
|
+
| Date strings (`"2023-05-01"`) | `datetime.date` | Extracts date from datetime strings too |
|
|
323
|
+
| Datetime strings | `pendulum.DateTime` | Naive datetimes treated as UTC |
|
|
324
|
+
| Time strings (`"07:00:00"`, `"03:11"`) | `datetime.time` | Handles both `HH:MM:SS` and `HH:MM` |
|
|
325
|
+
| Numeric values | `Decimal` | Preserves precision for financial data |
|
|
326
|
+
| Signal type strings | `SignalType` enum | `None` passes through as `None` |
|
|
327
|
+
| Rate type strings | `RateType` enum | Unknown values pass through as strings |
|
|
328
|
+
| Unit strings | `Unit` enum | Unknown values pass through as strings |
|
|
329
|
+
| Day type strings | `DayType` enum | `None` passes through (historical data) |
|
|
330
|
+
| `"None"` string (API_Url) | `None` | MIDAS API quirk |
|
|
331
|
+
|
|
332
|
+
## Context Manager
|
|
333
|
+
|
|
334
|
+
The client supports context manager protocol for clean resource management:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
from midas import create_auto_client
|
|
338
|
+
|
|
339
|
+
with create_auto_client("user", "pass") as client:
|
|
340
|
+
rins = client.rin_list()
|
|
341
|
+
rate = client.rate_values(rins[0].id)
|
|
342
|
+
# httpx client is closed automatically
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Project Structure
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
src/midas/
|
|
349
|
+
__init__.py # Public API re-exports
|
|
350
|
+
py.typed # PEP 561 type-checking marker
|
|
351
|
+
client.py # MIDASClient, create_client, create_auto_client
|
|
352
|
+
auth.py # BearerAuth, BasicAuth, AutoTokenAuth, get_token
|
|
353
|
+
enums.py # SignalType, RateType, Unit, DayType
|
|
354
|
+
entities/
|
|
355
|
+
__init__.py # Coercion dispatch functions
|
|
356
|
+
models.py # Pydantic models: RateInfo, ValueData, RinListEntry, Holiday, LookupEntry
|
|
357
|
+
tests/
|
|
358
|
+
test_entities.py # Entity coercion from raw fixture dicts
|
|
359
|
+
test_client.py # HTTP client tests with pytest-httpx
|
|
360
|
+
test_auth.py # Token parsing, expiry, auth headers
|
|
361
|
+
test_integration.py # Live API tests (requires MIDAS credentials)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Development
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# Install with dev dependencies
|
|
368
|
+
pip install -e ".[dev]"
|
|
369
|
+
|
|
370
|
+
# Lint
|
|
371
|
+
ruff check src/ tests/
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Tests
|
|
375
|
+
|
|
376
|
+
The test suite has two tiers:
|
|
377
|
+
|
|
378
|
+
**Unit tests** run entirely offline using fixture dicts and mocked HTTP (pytest-httpx):
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
pytest -m "not integration"
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Integration tests** run against the live MIDAS API at `midasapi.energy.ca.gov`. They require credentials in environment variables and are skipped automatically when the variables are not set:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
export MIDAS_USERNAME="you@example.com"
|
|
388
|
+
export MIDAS_PASSWORD="your-password"
|
|
389
|
+
pytest -m integration
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Integration tests exercise the full auth flow (token acquisition, expiry checks), every endpoint (RIN list, rate values, lookup tables, holidays, historical list/data), all entity coercion paths against real response shapes, and the signal type helpers (GHG, Flex Alert detection).
|
|
393
|
+
|
|
394
|
+
Note that the MIDAS API server can be slow (5-20+ seconds per request is normal), so the integration suite takes a few minutes to complete. Run everything together with just `pytest`.
|
|
395
|
+
|
|
396
|
+
## Related Projects
|
|
397
|
+
|
|
398
|
+
- **[midas-api-specs](https://github.com/grid-coordination/midas-api-specs)** — OpenAPI specifications for the MIDAS API, derived from documentation and live API validation
|
|
399
|
+
- **[clj-midas](https://github.com/grid-coordination/clj-midas)** — Clojure client for the MIDAS API (Martian-based, spec-driven)
|
|
400
|
+
- **[python-oa3](https://github.com/grid-coordination/python-oa3)** — Python client for OpenADR 3 (same entity API pattern)
|
|
401
|
+
|
|
402
|
+
## License
|
|
403
|
+
|
|
404
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
midas/__init__.py,sha256=6KNBi8eoH9KuzfnEhXqjqa2RU4nW183454lZHDcbCjQ,1223
|
|
2
|
+
midas/auth.py,sha256=tCXEVrhnTrkYbyOSnWXHvBiAqk1QJaJIc2lPOqz2brA,3307
|
|
3
|
+
midas/client.py,sha256=SV7L8nOme25yXcGEGYH9T5igQhuZ8nuyfILBtiDaRNo,6513
|
|
4
|
+
midas/enums.py,sha256=u688kEIX6eBOueY1VcdeoHI2wtJyTbyzfiJ3-IRPQ0E,869
|
|
5
|
+
midas/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
midas/entities/__init__.py,sha256=L8lS5g8vXeEeOF2KEVFrYolvoGRs4Ik-Bw0M1Rsj53c,1633
|
|
7
|
+
midas/entities/models.py,sha256=cCZbwDtBjA4XnSeyOXIRtWSyAdUaoJBEsEDQqXff9OU,6685
|
|
8
|
+
python_midas-0.1.0.dist-info/METADATA,sha256=bX6CKeFaqFH2kS1Hy7j5krJat3bfdXX4zk00A6Gesb4,14523
|
|
9
|
+
python_midas-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
python_midas-0.1.0.dist-info/licenses/LICENSE,sha256=SNfEtQtKuaLySMOhEdErGT_hoKN-6L0BJlPJpUXZoDI,1089
|
|
11
|
+
python_midas-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clark Communications Corporation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|