python-midas 0.1.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

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