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 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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.