python-midas 0.1.1__py3-none-any.whl → 1.0.1__py3-none-any.whl

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