omixom-data 0.3.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.
- omixom_data/__init__.py +68 -0
- omixom_data/client.py +277 -0
- omixom_data/errors.py +51 -0
- omixom_data/events.py +30 -0
- omixom_data/feed.py +290 -0
- omixom_data/schemas.py +120 -0
- omixom_data/state.py +45 -0
- omixom_data-0.3.0.dist-info/METADATA +147 -0
- omixom_data-0.3.0.dist-info/RECORD +10 -0
- omixom_data-0.3.0.dist-info/WHEEL +4 -0
omixom_data/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Cliente Python de la Omixom Data API v3.
|
|
2
|
+
|
|
3
|
+
El camino recomendado es el feed de réplica en batches: cada batch es una página de trabajo
|
|
4
|
+
acotado que trae sus eventos y el estado que la deja atrás.
|
|
5
|
+
|
|
6
|
+
from omixom_data import Client
|
|
7
|
+
|
|
8
|
+
with Client(token="...") as client:
|
|
9
|
+
feed = client.feed([30125, 30126])
|
|
10
|
+
for batch in feed.batches():
|
|
11
|
+
apply_all(batch.events) # aplicar primero
|
|
12
|
+
save(batch.state.model_dump_json()) # persistir después
|
|
13
|
+
|
|
14
|
+
Y para continuar en la próxima corrida, `client.resume(FeedState.model_validate_json(saved))`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from omixom_data.client import DEFAULT_BASE_URL, Client
|
|
18
|
+
from omixom_data.errors import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
InvalidRequestError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
OmixomDataError,
|
|
23
|
+
RateLimitedError,
|
|
24
|
+
ServerError,
|
|
25
|
+
StateError,
|
|
26
|
+
)
|
|
27
|
+
from omixom_data.events import FeedEvent, MeasurementDeleted, MeasurementUpserted
|
|
28
|
+
from omixom_data.feed import Batch, Feed
|
|
29
|
+
from omixom_data.schemas import (
|
|
30
|
+
Category,
|
|
31
|
+
Change,
|
|
32
|
+
ChangesPage,
|
|
33
|
+
Measurement,
|
|
34
|
+
ModuleDetail,
|
|
35
|
+
SeriesModule,
|
|
36
|
+
SeriesPage,
|
|
37
|
+
StationDetail,
|
|
38
|
+
StationSummary,
|
|
39
|
+
)
|
|
40
|
+
from omixom_data.state import FeedState, ModuleState
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_BASE_URL",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"Batch",
|
|
46
|
+
"Category",
|
|
47
|
+
"Change",
|
|
48
|
+
"ChangesPage",
|
|
49
|
+
"Client",
|
|
50
|
+
"Feed",
|
|
51
|
+
"FeedEvent",
|
|
52
|
+
"FeedState",
|
|
53
|
+
"InvalidRequestError",
|
|
54
|
+
"Measurement",
|
|
55
|
+
"MeasurementDeleted",
|
|
56
|
+
"MeasurementUpserted",
|
|
57
|
+
"ModuleDetail",
|
|
58
|
+
"ModuleState",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"OmixomDataError",
|
|
61
|
+
"RateLimitedError",
|
|
62
|
+
"SeriesModule",
|
|
63
|
+
"SeriesPage",
|
|
64
|
+
"ServerError",
|
|
65
|
+
"StateError",
|
|
66
|
+
"StationDetail",
|
|
67
|
+
"StationSummary",
|
|
68
|
+
]
|
omixom_data/client.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Cliente HTTP de la Omixom Data API v3.
|
|
2
|
+
|
|
3
|
+
Expone las lecturas crudas de la API (listado, detalle, página de serie, página de cambios) y
|
|
4
|
+
la fábrica del flujo guiado (`feed`/`resume`), que es el camino recomendado para replicar
|
|
5
|
+
mediciones. Maneja autenticación por token, errores en formato problem+json y los límites de
|
|
6
|
+
uso: ante un 429 espera lo que indique `Retry-After` y reintenta, así un bootstrap largo avanza
|
|
7
|
+
solo (desactivable con `wait_on_rate_limit=False`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from omixom_data.errors import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
InvalidRequestError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
OmixomDataError,
|
|
23
|
+
RateLimitedError,
|
|
24
|
+
ServerError,
|
|
25
|
+
)
|
|
26
|
+
from omixom_data.feed import Feed, flatten_series_page
|
|
27
|
+
from omixom_data.schemas import (
|
|
28
|
+
Category,
|
|
29
|
+
ChangesPage,
|
|
30
|
+
Measurement,
|
|
31
|
+
SeriesPage,
|
|
32
|
+
StationDetail,
|
|
33
|
+
StationSummary,
|
|
34
|
+
)
|
|
35
|
+
from omixom_data.state import FeedState, ModuleState
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_URL = "https://clima.omixom.com"
|
|
38
|
+
# La forma de query params que httpx acepta como lista de pares.
|
|
39
|
+
_Params = list[tuple[str, str | int | float | None]]
|
|
40
|
+
# Cota de una espera individual por rate limit; un Retry-After mayor se considera anómalo.
|
|
41
|
+
MAX_RATE_LIMIT_WAIT = 3600.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Client:
|
|
45
|
+
"""Acceso a la Omixom Data API v3 con un token de la red Omixom.
|
|
46
|
+
|
|
47
|
+
Uso típico:
|
|
48
|
+
|
|
49
|
+
with Client(token="...") as client:
|
|
50
|
+
feed = client.feed([30125, 30126])
|
|
51
|
+
for batch in feed.batches():
|
|
52
|
+
apply_all(batch.events)
|
|
53
|
+
save(batch.state.model_dump_json())
|
|
54
|
+
|
|
55
|
+
Las lecturas crudas (`stations`, `station`, `series_page`, `changes_page`) devuelven los
|
|
56
|
+
payloads tipados tal cual; el manejo del cursor queda a cargo del caller. Para replicar
|
|
57
|
+
series conviene `feed`/`resume`, que lo encapsulan.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
token: str,
|
|
63
|
+
*,
|
|
64
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
65
|
+
timeout: float = 30.0,
|
|
66
|
+
wait_on_rate_limit: bool = True,
|
|
67
|
+
transport: httpx.BaseTransport | None = None,
|
|
68
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._http = httpx.Client(
|
|
71
|
+
base_url=f"{base_url.rstrip('/')}/api/v3",
|
|
72
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
transport=transport,
|
|
75
|
+
)
|
|
76
|
+
self._wait_on_rate_limit = wait_on_rate_limit
|
|
77
|
+
self._sleep = sleep
|
|
78
|
+
|
|
79
|
+
def stations(self) -> list[StationSummary]:
|
|
80
|
+
"""Lista los equipos accesibles con el token."""
|
|
81
|
+
payload = self._get("/stations")
|
|
82
|
+
return [StationSummary.model_validate(row) for row in payload["stations"]]
|
|
83
|
+
|
|
84
|
+
def station(self, code: int) -> StationDetail:
|
|
85
|
+
"""Detalle de un equipo: fecha de instalación y módulos."""
|
|
86
|
+
return StationDetail.model_validate(self._get(f"/stations/{code}"))
|
|
87
|
+
|
|
88
|
+
def series(
|
|
89
|
+
self,
|
|
90
|
+
code: int,
|
|
91
|
+
*,
|
|
92
|
+
date_from: datetime,
|
|
93
|
+
date_to: datetime,
|
|
94
|
+
modules: Sequence[int] | None = None,
|
|
95
|
+
categories: Sequence[Category] | None = None,
|
|
96
|
+
) -> Iterator[Measurement]:
|
|
97
|
+
"""Serie completa de la ventana `[date_from, date_to)`, en una sola lectura coherente.
|
|
98
|
+
|
|
99
|
+
Camina todas las páginas del snapshot repitiendo el cursor de la primera, así el
|
|
100
|
+
resultado entero es un corte de la base en un único instante aunque haya escrituras
|
|
101
|
+
concurrentes. Streamea de a una página: se consume iterando; para materializar,
|
|
102
|
+
`list(...)`. Dentro de cada página los módulos van ascendentes y sus tiempos ordenados;
|
|
103
|
+
páginas sucesivas avanzan en el tiempo. Para réplicas que se mantienen al día, `feed`.
|
|
104
|
+
"""
|
|
105
|
+
requested = list(categories) if categories is not None else [Category.OK]
|
|
106
|
+
cursor: datetime | None = None
|
|
107
|
+
start = date_from
|
|
108
|
+
while True:
|
|
109
|
+
page = self.series_page(
|
|
110
|
+
code,
|
|
111
|
+
date_from=start,
|
|
112
|
+
date_to=date_to,
|
|
113
|
+
modules=modules,
|
|
114
|
+
categories=requested,
|
|
115
|
+
cursor=cursor,
|
|
116
|
+
)
|
|
117
|
+
yield from flatten_series_page(code, page, requested, into=Measurement)
|
|
118
|
+
if not page.has_more or page.next_from is None:
|
|
119
|
+
return
|
|
120
|
+
cursor = page.cursor
|
|
121
|
+
start = page.next_from
|
|
122
|
+
|
|
123
|
+
def series_page(
|
|
124
|
+
self,
|
|
125
|
+
code: int,
|
|
126
|
+
*,
|
|
127
|
+
date_from: datetime,
|
|
128
|
+
date_to: datetime,
|
|
129
|
+
modules: Sequence[int] | None = None,
|
|
130
|
+
categories: Sequence[Category] | None = None,
|
|
131
|
+
cursor: datetime | None = None,
|
|
132
|
+
) -> SeriesPage:
|
|
133
|
+
"""Una página del snapshot de la serie en `[date_from, date_to)`.
|
|
134
|
+
|
|
135
|
+
Para paginar de forma coherente, repetir el request con `date_from` igual al
|
|
136
|
+
`next_from` recibido y `cursor` igual al `cursor` recibido.
|
|
137
|
+
"""
|
|
138
|
+
params: _Params = [
|
|
139
|
+
("date_from", date_from.isoformat()),
|
|
140
|
+
("date_to", date_to.isoformat()),
|
|
141
|
+
]
|
|
142
|
+
params.extend(("modules", str(module)) for module in modules or ())
|
|
143
|
+
params.extend(("category", category.wire_name) for category in categories or ())
|
|
144
|
+
if cursor is not None:
|
|
145
|
+
params.append(("cursor", cursor.isoformat()))
|
|
146
|
+
return SeriesPage.model_validate(self._get(f"/stations/{code}/measurements/series", params))
|
|
147
|
+
|
|
148
|
+
def changes_page(
|
|
149
|
+
self,
|
|
150
|
+
code: int,
|
|
151
|
+
*,
|
|
152
|
+
cursor: datetime,
|
|
153
|
+
modules: Sequence[int] | None = None,
|
|
154
|
+
date_from: datetime | None = None,
|
|
155
|
+
date_to: datetime | None = None,
|
|
156
|
+
) -> ChangesPage:
|
|
157
|
+
"""Una página del feed de cambios registrados después de `cursor`."""
|
|
158
|
+
params: _Params = [("cursor", cursor.isoformat())]
|
|
159
|
+
params.extend(("modules", str(module)) for module in modules or ())
|
|
160
|
+
if date_from is not None:
|
|
161
|
+
params.append(("date_from", date_from.isoformat()))
|
|
162
|
+
if date_to is not None:
|
|
163
|
+
params.append(("date_to", date_to.isoformat()))
|
|
164
|
+
return ChangesPage.model_validate(
|
|
165
|
+
self._get(f"/stations/{code}/measurements/changes", params)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def feed(
|
|
169
|
+
self,
|
|
170
|
+
stations: Sequence[int],
|
|
171
|
+
*,
|
|
172
|
+
date_from: datetime | None = None,
|
|
173
|
+
date_to: datetime | None = None,
|
|
174
|
+
modules: Sequence[int] | None = None,
|
|
175
|
+
categories: Sequence[Category] | None = None,
|
|
176
|
+
) -> Feed:
|
|
177
|
+
"""Feed de réplica para un grupo de estaciones.
|
|
178
|
+
|
|
179
|
+
Sin fechas replica toda la historia de cada equipo (desde su fecha de instalación) y
|
|
180
|
+
sigue los cambios para siempre. `date_from` acota el inicio ("desde 2023 en adelante")
|
|
181
|
+
y `date_to` cierra la ventana ("de 2023 a 2024"); las fechas deben ser timezone-aware.
|
|
182
|
+
|
|
183
|
+
`modules` acota a esos módulos, de cualquiera de las estaciones dadas: una estación sin
|
|
184
|
+
módulos seleccionados no genera requests, y un módulo que no pertenece a ninguna es
|
|
185
|
+
`ValueError`. El conjunto replicado queda fijado acá: un módulo instalado en el equipo
|
|
186
|
+
después de crear el feed no se suma solo.
|
|
187
|
+
"""
|
|
188
|
+
for bound, name in ((date_from, "date_from"), (date_to, "date_to")):
|
|
189
|
+
if bound is not None and bound.tzinfo is None:
|
|
190
|
+
raise ValueError(f"{name} debe ser timezone-aware")
|
|
191
|
+
if date_from is not None and date_to is not None and date_from > date_to:
|
|
192
|
+
raise ValueError("date_from debe ser menor o igual a date_to")
|
|
193
|
+
remaining = set(modules) if modules is not None else None
|
|
194
|
+
module_states: dict[int, ModuleState] = {}
|
|
195
|
+
for code in stations:
|
|
196
|
+
owned = [module.id for module in self.station(code).modules]
|
|
197
|
+
selected = owned if remaining is None else [m for m in owned if m in remaining]
|
|
198
|
+
if remaining is not None:
|
|
199
|
+
remaining.difference_update(selected)
|
|
200
|
+
for module in selected:
|
|
201
|
+
module_states[module] = ModuleState(station=code)
|
|
202
|
+
if remaining:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"módulos que no pertenecen a las estaciones dadas: {sorted(remaining)}"
|
|
205
|
+
)
|
|
206
|
+
state = FeedState(
|
|
207
|
+
modules=module_states,
|
|
208
|
+
date_from=date_from,
|
|
209
|
+
date_to=date_to,
|
|
210
|
+
categories=[int(c) for c in categories] if categories is not None else None,
|
|
211
|
+
)
|
|
212
|
+
return Feed(self, state)
|
|
213
|
+
|
|
214
|
+
def resume(self, state: FeedState) -> Feed:
|
|
215
|
+
"""Continúa un feed desde un estado persistido con `FeedState.model_dump_json()`."""
|
|
216
|
+
state.require_supported_version()
|
|
217
|
+
return Feed(self, state)
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
"""Cierra la conexión HTTP subyacente."""
|
|
221
|
+
self._http.close()
|
|
222
|
+
|
|
223
|
+
def __enter__(self) -> "Client":
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(
|
|
227
|
+
self,
|
|
228
|
+
exc_type: type[BaseException] | None,
|
|
229
|
+
exc: BaseException | None,
|
|
230
|
+
tb: TracebackType | None,
|
|
231
|
+
) -> None:
|
|
232
|
+
self.close()
|
|
233
|
+
|
|
234
|
+
def _get(self, path: str, params: _Params | None = None) -> dict[str, Any]:
|
|
235
|
+
while True:
|
|
236
|
+
response = self._http.get(path, params=params)
|
|
237
|
+
if response.status_code == 429 and self._wait_on_rate_limit:
|
|
238
|
+
self._sleep(min(_retry_after(response) or 1.0, MAX_RATE_LIMIT_WAIT))
|
|
239
|
+
continue
|
|
240
|
+
if response.is_success:
|
|
241
|
+
return response.json()
|
|
242
|
+
raise _to_error(response)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
246
|
+
header = response.headers.get("Retry-After")
|
|
247
|
+
try:
|
|
248
|
+
return float(header) if header is not None else None
|
|
249
|
+
except ValueError:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _to_error(response: httpx.Response) -> OmixomDataError:
|
|
254
|
+
detail = _problem_detail(response)
|
|
255
|
+
status = response.status_code
|
|
256
|
+
if status in (401, 403):
|
|
257
|
+
return AuthenticationError(detail)
|
|
258
|
+
if status == 404:
|
|
259
|
+
return NotFoundError(detail)
|
|
260
|
+
if status == 422:
|
|
261
|
+
return InvalidRequestError(detail)
|
|
262
|
+
if status == 429:
|
|
263
|
+
return RateLimitedError(detail, retry_after=_retry_after(response))
|
|
264
|
+
if status >= 500:
|
|
265
|
+
return ServerError(detail)
|
|
266
|
+
return OmixomDataError(detail)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _problem_detail(response: httpx.Response) -> str:
|
|
270
|
+
try:
|
|
271
|
+
problem = response.json()
|
|
272
|
+
return problem.get("detail") or problem.get("title") or response.text
|
|
273
|
+
except ValueError:
|
|
274
|
+
return f"HTTP {response.status_code}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
__all__ = ["DEFAULT_BASE_URL", "MAX_RATE_LIMIT_WAIT", "Client"]
|
omixom_data/errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Jerarquía de errores del cliente: cada respuesta de error de la API mapea a un tipo propio.
|
|
2
|
+
|
|
3
|
+
La API responde errores en formato RFC 9457 (`application/problem+json`); el mensaje de cada
|
|
4
|
+
excepción lleva el `detail` (o el `title`) del problem recibido.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OmixomDataError(Exception):
|
|
9
|
+
"""Raíz de todo error del cliente. Capturarla atrapa cualquier falla de la API."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(OmixomDataError):
|
|
13
|
+
"""El token falta, es inválido o no tiene acceso al recurso (401/403)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(OmixomDataError):
|
|
17
|
+
"""El equipo pedido no existe o está fuera del alcance del token (404)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidRequestError(OmixomDataError):
|
|
21
|
+
"""La API rechazó los parámetros del request (422)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitedError(OmixomDataError):
|
|
25
|
+
"""Límite de uso excedido (429) y el cliente está configurado para no esperar.
|
|
26
|
+
|
|
27
|
+
`retry_after` indica cuántos segundos esperar antes de reintentar.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, retry_after: float | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.retry_after = retry_after
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ServerError(OmixomDataError):
|
|
36
|
+
"""La API falló con un error interno (5xx)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StateError(OmixomDataError):
|
|
40
|
+
"""El estado de feed provisto es inconsistente o de una versión desconocida."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"InvalidRequestError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"OmixomDataError",
|
|
48
|
+
"RateLimitedError",
|
|
49
|
+
"ServerError",
|
|
50
|
+
"StateError",
|
|
51
|
+
]
|
omixom_data/events.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Eventos que emite el feed: la interfaz única que el consumidor aplica a su copia.
|
|
2
|
+
|
|
3
|
+
Tanto el bootstrap como los cambios posteriores llegan como estos dos eventos, así el consumidor
|
|
4
|
+
escribe una sola función de aplicación: upsert de `(station, module, time) -> value` para
|
|
5
|
+
`MeasurementUpserted`, eliminación para `MeasurementDeleted`. Aplicarlos es idempotente por
|
|
6
|
+
diseño: re-aplicar un evento deja la copia igual.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from omixom_data.schemas import Measurement
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeasurementUpserted(Measurement):
|
|
17
|
+
"""Una medición nueva o corregida: dejar la copia en este valor."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MeasurementDeleted(BaseModel):
|
|
21
|
+
"""Una medición que dejó de existir (o salió de la vista filtrada): quitarla de la copia."""
|
|
22
|
+
|
|
23
|
+
station: int
|
|
24
|
+
module: int
|
|
25
|
+
time: datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
FeedEvent = MeasurementUpserted | MeasurementDeleted
|
|
29
|
+
|
|
30
|
+
__all__ = ["FeedEvent", "MeasurementDeleted", "MeasurementUpserted"]
|
omixom_data/feed.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Flujo guiado de réplica en batches: una página HTTP por batch, con checkpoint alineado.
|
|
2
|
+
|
|
3
|
+
`Feed.batches()` emite la secuencia que mantiene una copia al día: primero el bootstrap de cada
|
|
4
|
+
módulo (páginas de `/series` leídas as-of un mismo cursor) y después lo registrado desde cada
|
|
5
|
+
cursor (`/changes`), hasta quedar al día. Cada `Batch` trae los eventos de una página y el
|
|
6
|
+
estado ya avanzado más allá de ella: el contrato del consumidor es aplicar los eventos primero
|
|
7
|
+
y persistir `batch.state` después (o ambos en una transacción de su store). Con ese orden, un
|
|
8
|
+
corte en cualquier punto deja como peor caso una página re-aplicada, que el replay idempotente
|
|
9
|
+
absorbe. `batches()` es re-llamable: cada llamada avanza hasta quedar al día y la siguiente
|
|
10
|
+
retoma desde ahí.
|
|
11
|
+
|
|
12
|
+
El estado es por módulo y los requests por estación: los módulos que comparten cursor viajan
|
|
13
|
+
juntos en un mismo request (el caso común es todos los del equipo), el bootstrap avanza
|
|
14
|
+
round-robin entre estaciones para solapar sus límites de uso, y un grupo con cursores
|
|
15
|
+
desparejos se re-unifica pidiendo los cambios desde el mínimo: la re-entrega para los más
|
|
16
|
+
avanzados la absorbe la idempotencia y todos salen con el mismo cursor. Con un filtro de
|
|
17
|
+
categorías, un punto corregido hacia una categoría no seleccionada se emite como borrado:
|
|
18
|
+
salió de la vista replicada.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterator, Sequence
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Literal, Protocol, TypeVar
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from omixom_data.errors import StateError
|
|
28
|
+
from omixom_data.events import FeedEvent, MeasurementDeleted, MeasurementUpserted
|
|
29
|
+
from omixom_data.schemas import (
|
|
30
|
+
Category,
|
|
31
|
+
Change,
|
|
32
|
+
ChangesPage,
|
|
33
|
+
Measurement,
|
|
34
|
+
SeriesPage,
|
|
35
|
+
StationDetail,
|
|
36
|
+
)
|
|
37
|
+
from omixom_data.state import FeedState, ModuleState
|
|
38
|
+
|
|
39
|
+
# Piso del bootstrap cuando el equipo no tiene fecha de instalación registrada.
|
|
40
|
+
DEFAULT_START = datetime(2000, 1, 1, tzinfo=UTC)
|
|
41
|
+
# Fin abierto: la API exige ventanas cerradas, así que "en adelante" se pide hasta acá.
|
|
42
|
+
FAR_FUTURE = datetime(9999, 1, 1, tzinfo=UTC)
|
|
43
|
+
|
|
44
|
+
_MeasurementT = TypeVar("_MeasurementT", bound=Measurement)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def flatten_series_page(
|
|
48
|
+
code: int,
|
|
49
|
+
page: SeriesPage,
|
|
50
|
+
requested: Sequence[Category],
|
|
51
|
+
*,
|
|
52
|
+
into: type[_MeasurementT],
|
|
53
|
+
) -> Iterator[_MeasurementT]:
|
|
54
|
+
"""Aplana la página columnar a una medición por punto, módulos en orden ascendente.
|
|
55
|
+
|
|
56
|
+
Cuando el request filtró una sola categoría el wire omite `categories`; el valor se repone
|
|
57
|
+
con la categoría pedida. `meta` omitido es `None` por punto.
|
|
58
|
+
"""
|
|
59
|
+
for module in sorted(page.modules):
|
|
60
|
+
series = page.modules[module]
|
|
61
|
+
count = len(series.times)
|
|
62
|
+
categories = series.categories or [int(requested[0])] * count
|
|
63
|
+
meta = series.meta or [None] * count
|
|
64
|
+
for i in range(count):
|
|
65
|
+
yield into(
|
|
66
|
+
station=code,
|
|
67
|
+
module=module,
|
|
68
|
+
time=series.times[i],
|
|
69
|
+
value=series.values[i],
|
|
70
|
+
category=categories[i],
|
|
71
|
+
meta=meta[i],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SeriesApi(Protocol):
|
|
76
|
+
"""Lo que el feed necesita del cliente HTTP; `Client` lo satisface estructuralmente."""
|
|
77
|
+
|
|
78
|
+
def station(self, code: int) -> StationDetail:
|
|
79
|
+
"""Detalle del equipo, para derivar el inicio del bootstrap."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def series_page(
|
|
83
|
+
self,
|
|
84
|
+
code: int,
|
|
85
|
+
*,
|
|
86
|
+
date_from: datetime,
|
|
87
|
+
date_to: datetime,
|
|
88
|
+
modules: Sequence[int] | None = None,
|
|
89
|
+
categories: Sequence[Category] | None = None,
|
|
90
|
+
cursor: datetime | None = None,
|
|
91
|
+
) -> SeriesPage:
|
|
92
|
+
"""Una página del snapshot as-of `cursor`."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
def changes_page(
|
|
96
|
+
self,
|
|
97
|
+
code: int,
|
|
98
|
+
*,
|
|
99
|
+
cursor: datetime,
|
|
100
|
+
modules: Sequence[int] | None = None,
|
|
101
|
+
date_from: datetime | None = None,
|
|
102
|
+
date_to: datetime | None = None,
|
|
103
|
+
) -> ChangesPage:
|
|
104
|
+
"""Una página del feed de cambios posteriores a `cursor`."""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Batch(BaseModel):
|
|
109
|
+
"""Una página del sync: los eventos a aplicar y el estado que la deja atrás.
|
|
110
|
+
|
|
111
|
+
Aplicar `events` primero y persistir `state` después: `state` ya incluye el avance de este
|
|
112
|
+
batch, así que persistirlo sin haber aplicado puede perder eventos, mientras que aplicar
|
|
113
|
+
sin llegar a persistir solo repite la página en la próxima corrida.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
station: int
|
|
117
|
+
modules: list[int]
|
|
118
|
+
phase: Literal["bootstrap", "changes"]
|
|
119
|
+
events: list[FeedEvent]
|
|
120
|
+
state: FeedState
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Feed:
|
|
124
|
+
"""Réplica incremental de un grupo de estaciones; se construye con `Client.feed`/`resume`."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, api: SeriesApi, state: FeedState) -> None:
|
|
127
|
+
self._api = api
|
|
128
|
+
self._state = state
|
|
129
|
+
self._starts: dict[int, datetime] = {}
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def state(self) -> FeedState:
|
|
133
|
+
"""Snapshot serializable del avance; para checkpoints alineados usar `batch.state`."""
|
|
134
|
+
return self._state.model_copy(deep=True)
|
|
135
|
+
|
|
136
|
+
def batches(self) -> Iterator[Batch]:
|
|
137
|
+
"""Un batch por página hasta dejar la copia al día; re-llamable para re-sincronizar."""
|
|
138
|
+
yield from self._bootstrap_rounds()
|
|
139
|
+
yield from self._follow_rounds()
|
|
140
|
+
|
|
141
|
+
def add_modules(self, station: int, modules: Sequence[int] | None = None) -> list[int]:
|
|
142
|
+
"""Suma módulos de `station` al feed; sin `modules`, todos los del equipo que falten.
|
|
143
|
+
|
|
144
|
+
Devuelve los ids agregados, omitiendo los ya rastreados; un id que no pertenece al
|
|
145
|
+
equipo es `ValueError`. Los nuevos hacen su bootstrap en el próximo `batches()` sin
|
|
146
|
+
tocar el avance del resto.
|
|
147
|
+
"""
|
|
148
|
+
owned = [module.id for module in self._api.station(station).modules]
|
|
149
|
+
if modules is None:
|
|
150
|
+
selected = owned
|
|
151
|
+
else:
|
|
152
|
+
unknown = sorted(set(modules) - set(owned))
|
|
153
|
+
if unknown:
|
|
154
|
+
raise ValueError(f"módulos que no pertenecen a la estación {station}: {unknown}")
|
|
155
|
+
selected = list(modules)
|
|
156
|
+
added = sorted(module for module in selected if module not in self._state.modules)
|
|
157
|
+
for module in added:
|
|
158
|
+
self._state.modules[module] = ModuleState(station=station)
|
|
159
|
+
return added
|
|
160
|
+
|
|
161
|
+
def _bootstrap_rounds(self) -> Iterator[Batch]:
|
|
162
|
+
while True:
|
|
163
|
+
progressed = False
|
|
164
|
+
for station in self._stations():
|
|
165
|
+
group = self._pending_group(station)
|
|
166
|
+
if not group:
|
|
167
|
+
continue
|
|
168
|
+
progressed = True
|
|
169
|
+
yield self._bootstrap_page(station, group)
|
|
170
|
+
if not progressed:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
def _follow_rounds(self) -> Iterator[Batch]:
|
|
174
|
+
active = {station: self._station_modules(station) for station in self._stations()}
|
|
175
|
+
while active:
|
|
176
|
+
for station in sorted(active):
|
|
177
|
+
batch, exhausted = self._follow_page(station, active[station])
|
|
178
|
+
if exhausted:
|
|
179
|
+
del active[station]
|
|
180
|
+
yield batch
|
|
181
|
+
|
|
182
|
+
def _pending_group(self, station: int) -> list[int]:
|
|
183
|
+
"""Los módulos pendientes de la estación que comparten posición de bootstrap."""
|
|
184
|
+
pending = [
|
|
185
|
+
module
|
|
186
|
+
for module in self._station_modules(station)
|
|
187
|
+
if not self._state.modules[module].bootstrapped
|
|
188
|
+
]
|
|
189
|
+
if not pending:
|
|
190
|
+
return []
|
|
191
|
+
first = self._state.modules[pending[0]]
|
|
192
|
+
return [
|
|
193
|
+
module
|
|
194
|
+
for module in pending
|
|
195
|
+
if (self._state.modules[module].cursor, self._state.modules[module].next_from)
|
|
196
|
+
== (first.cursor, first.next_from)
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def _bootstrap_page(self, station: int, group: list[int]) -> Batch:
|
|
200
|
+
first = self._state.modules[group[0]]
|
|
201
|
+
page = self._api.series_page(
|
|
202
|
+
station,
|
|
203
|
+
date_from=first.next_from or self._window_start(station),
|
|
204
|
+
date_to=self._state.date_to or FAR_FUTURE,
|
|
205
|
+
modules=group,
|
|
206
|
+
categories=self._categories(),
|
|
207
|
+
cursor=first.cursor,
|
|
208
|
+
)
|
|
209
|
+
events: list[FeedEvent] = list(
|
|
210
|
+
flatten_series_page(station, page, self._categories(), into=MeasurementUpserted)
|
|
211
|
+
)
|
|
212
|
+
for module in group:
|
|
213
|
+
module_state = self._state.modules[module]
|
|
214
|
+
module_state.cursor = page.cursor
|
|
215
|
+
module_state.next_from = page.next_from
|
|
216
|
+
module_state.bootstrapped = not page.has_more
|
|
217
|
+
return Batch(
|
|
218
|
+
station=station, modules=group, phase="bootstrap", events=events, state=self.state
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _follow_page(self, station: int, group: list[int]) -> tuple[Batch, bool]:
|
|
222
|
+
cursors: list[datetime] = []
|
|
223
|
+
for module in group:
|
|
224
|
+
cursor = self._state.modules[module].cursor
|
|
225
|
+
if cursor is None:
|
|
226
|
+
raise StateError(f"el módulo {module} no tiene cursor: estado corrupto")
|
|
227
|
+
cursors.append(cursor)
|
|
228
|
+
time_from, time_to = self._changes_window()
|
|
229
|
+
page = self._api.changes_page(
|
|
230
|
+
station,
|
|
231
|
+
cursor=min(cursors),
|
|
232
|
+
modules=group,
|
|
233
|
+
date_from=time_from,
|
|
234
|
+
date_to=time_to,
|
|
235
|
+
)
|
|
236
|
+
events = [self._change_event(station, change) for change in page.changes]
|
|
237
|
+
for module in group:
|
|
238
|
+
self._state.modules[module].cursor = page.cursor
|
|
239
|
+
batch = Batch(
|
|
240
|
+
station=station, modules=group, phase="changes", events=events, state=self.state
|
|
241
|
+
)
|
|
242
|
+
return batch, not page.has_more
|
|
243
|
+
|
|
244
|
+
def _stations(self) -> list[int]:
|
|
245
|
+
return sorted({module_state.station for module_state in self._state.modules.values()})
|
|
246
|
+
|
|
247
|
+
def _station_modules(self, station: int) -> list[int]:
|
|
248
|
+
return sorted(
|
|
249
|
+
module
|
|
250
|
+
for module, module_state in self._state.modules.items()
|
|
251
|
+
if module_state.station == station
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _window_start(self, station: int) -> datetime:
|
|
255
|
+
if self._state.date_from is not None:
|
|
256
|
+
return self._state.date_from
|
|
257
|
+
if station not in self._starts:
|
|
258
|
+
installed = self._api.station(station).installation_date
|
|
259
|
+
self._starts[station] = (
|
|
260
|
+
DEFAULT_START
|
|
261
|
+
if installed is None
|
|
262
|
+
else datetime(installed.year, installed.month, installed.day, tzinfo=UTC)
|
|
263
|
+
)
|
|
264
|
+
return self._starts[station]
|
|
265
|
+
|
|
266
|
+
def _changes_window(self) -> tuple[datetime | None, datetime | None]:
|
|
267
|
+
if self._state.date_from is None:
|
|
268
|
+
return None, None
|
|
269
|
+
return self._state.date_from, self._state.date_to or FAR_FUTURE
|
|
270
|
+
|
|
271
|
+
def _categories(self) -> list[Category]:
|
|
272
|
+
if self._state.categories is None:
|
|
273
|
+
return [Category.OK]
|
|
274
|
+
return [Category(value) for value in self._state.categories]
|
|
275
|
+
|
|
276
|
+
def _change_event(self, code: int, change: Change) -> FeedEvent:
|
|
277
|
+
in_view = change.category in {int(c) for c in self._categories()}
|
|
278
|
+
if change.operation == "deleted" or not in_view:
|
|
279
|
+
return MeasurementDeleted(station=code, module=change.module, time=change.time)
|
|
280
|
+
return MeasurementUpserted(
|
|
281
|
+
station=code,
|
|
282
|
+
module=change.module,
|
|
283
|
+
time=change.time,
|
|
284
|
+
value=change.value,
|
|
285
|
+
category=change.category,
|
|
286
|
+
meta=change.meta,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = ["DEFAULT_START", "FAR_FUTURE", "Batch", "Feed", "SeriesApi", "flatten_series_page"]
|
omixom_data/schemas.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Formas de wire de la Omixom Data API v3, tipadas con Pydantic.
|
|
2
|
+
|
|
3
|
+
Espejan los payloads de los endpoints tal cual llegan; el cliente las expone directamente en
|
|
4
|
+
las lecturas de bajo nivel (`Client.series_page`/`Client.changes_page`) y las traduce a eventos
|
|
5
|
+
en el flujo guiado (`Feed`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Category(IntEnum):
|
|
16
|
+
"""Categorías de dato de la API; el valor es el int de wire y el nombre el de query."""
|
|
17
|
+
|
|
18
|
+
ERROR = -1
|
|
19
|
+
OK = 0
|
|
20
|
+
TESTING = 1
|
|
21
|
+
NOT_CALCULATED = 2
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def wire_name(self) -> str:
|
|
25
|
+
"""El nombre que la API acepta en el query param `category`."""
|
|
26
|
+
return self.name.lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Measurement(BaseModel):
|
|
30
|
+
"""Una medición de la serie de un módulo, identificada por `(station, module, time)`."""
|
|
31
|
+
|
|
32
|
+
station: int
|
|
33
|
+
module: int
|
|
34
|
+
time: datetime
|
|
35
|
+
value: float
|
|
36
|
+
category: int
|
|
37
|
+
meta: dict[str, Any] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StationSummary(BaseModel):
|
|
41
|
+
"""Equipo del listado de `GET /stations`."""
|
|
42
|
+
|
|
43
|
+
code: int
|
|
44
|
+
title: str
|
|
45
|
+
latitude: float
|
|
46
|
+
longitude: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModuleDetail(BaseModel):
|
|
50
|
+
"""Módulo dentro del detalle de un equipo."""
|
|
51
|
+
|
|
52
|
+
id: int
|
|
53
|
+
title: str
|
|
54
|
+
type: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class StationDetail(BaseModel):
|
|
58
|
+
"""Detalle de `GET /stations/{code}`: fecha de instalación y módulos."""
|
|
59
|
+
|
|
60
|
+
code: int
|
|
61
|
+
title: str
|
|
62
|
+
installation_date: date | None
|
|
63
|
+
modules: list[ModuleDetail]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SeriesModule(BaseModel):
|
|
67
|
+
"""Serie columnar de un módulo: arrays paralelos alineados por índice.
|
|
68
|
+
|
|
69
|
+
`categories` viene `null` cuando el request filtró una sola categoría; `flags` y `meta`
|
|
70
|
+
vienen `null` cuando ningún punto los tiene.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
times: list[datetime]
|
|
74
|
+
values: list[float]
|
|
75
|
+
categories: list[int] | None = None
|
|
76
|
+
flags: list[int | None] | None = None
|
|
77
|
+
meta: list[dict[str, Any] | None] | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SeriesPage(BaseModel):
|
|
81
|
+
"""Página de `GET /stations/{code}/measurements/series`."""
|
|
82
|
+
|
|
83
|
+
modules: dict[int, SeriesModule]
|
|
84
|
+
next_from: datetime | None
|
|
85
|
+
has_more: bool
|
|
86
|
+
cursor: datetime
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Change(BaseModel):
|
|
90
|
+
"""Cambio del feed de `GET /stations/{code}/measurements/changes`."""
|
|
91
|
+
|
|
92
|
+
module: int
|
|
93
|
+
time: datetime
|
|
94
|
+
value: float
|
|
95
|
+
category: int
|
|
96
|
+
meta: dict[str, Any] | None
|
|
97
|
+
operation: Literal["created", "updated", "deleted"]
|
|
98
|
+
revision: int
|
|
99
|
+
transaction_time: datetime
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChangesPage(BaseModel):
|
|
103
|
+
"""Página de `GET /stations/{code}/measurements/changes`."""
|
|
104
|
+
|
|
105
|
+
changes: list[Change]
|
|
106
|
+
cursor: datetime
|
|
107
|
+
has_more: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"Category",
|
|
112
|
+
"Change",
|
|
113
|
+
"ChangesPage",
|
|
114
|
+
"Measurement",
|
|
115
|
+
"ModuleDetail",
|
|
116
|
+
"SeriesModule",
|
|
117
|
+
"SeriesPage",
|
|
118
|
+
"StationDetail",
|
|
119
|
+
"StationSummary",
|
|
120
|
+
]
|
omixom_data/state.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Estado serializable de un feed: la configuración del alcance y el avance por módulo.
|
|
2
|
+
|
|
3
|
+
Es lo único que un consumidor persiste entre corridas: `batch.state.model_dump_json()` tras
|
|
4
|
+
aplicar cada batch, `FeedState.model_validate_json()` al arrancar y `Client.resume(state)` para
|
|
5
|
+
continuar donde quedó. El avance es por módulo (el módulo es la serie; la estación solo agrupa
|
|
6
|
+
los requests), y la configuración viaja adentro del estado porque el cursor solo vale para el
|
|
7
|
+
alcance con el que se generó: retomar con otros filtros exige un feed nuevo.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from omixom_data.errors import StateError
|
|
15
|
+
|
|
16
|
+
STATE_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ModuleState(BaseModel):
|
|
20
|
+
"""Avance de un módulo: su estación, el cursor del corte y la posición del bootstrap."""
|
|
21
|
+
|
|
22
|
+
station: int
|
|
23
|
+
cursor: datetime | None = None
|
|
24
|
+
next_from: datetime | None = None
|
|
25
|
+
bootstrapped: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FeedState(BaseModel):
|
|
29
|
+
"""Estado completo de un feed: alcance y avance por módulo."""
|
|
30
|
+
|
|
31
|
+
version: int = STATE_VERSION
|
|
32
|
+
modules: dict[int, ModuleState]
|
|
33
|
+
date_from: datetime | None = None
|
|
34
|
+
date_to: datetime | None = None
|
|
35
|
+
categories: list[int] | None = None
|
|
36
|
+
|
|
37
|
+
def require_supported_version(self) -> None:
|
|
38
|
+
"""Rechaza estados de una versión que este cliente no sabe interpretar."""
|
|
39
|
+
if self.version != STATE_VERSION:
|
|
40
|
+
raise StateError(
|
|
41
|
+
f"versión de estado {self.version} no soportada (esperada {STATE_VERSION})"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["STATE_VERSION", "FeedState", "ModuleState"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: omixom-data
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Cliente Python de la Omixom Data API v3: lectura de series y réplica incremental de mediciones.
|
|
5
|
+
Author: Omixom
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pydantic>=2.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# omixom-data
|
|
13
|
+
|
|
14
|
+
Cliente Python de la [Omixom Data API v3](https://clima.omixom.com/api/v3/docs): lectura de
|
|
15
|
+
series de mediciones y réplica incremental para mantener una copia siempre al día.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install omixom-data
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requiere Python 3.11+ y un token de acceso de la red Omixom.
|
|
22
|
+
|
|
23
|
+
## Mantener una copia al día
|
|
24
|
+
|
|
25
|
+
El caso típico es replicar las mediciones de un grupo de equipos y seguirlas en el tiempo. El
|
|
26
|
+
`Feed` encapsula todo el protocolo (bootstrap paginado, cursores, coherencia del snapshot) y lo
|
|
27
|
+
entrega en **batches**: cada batch es una página de trabajo acotado que trae sus eventos y el
|
|
28
|
+
estado que la deja atrás.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from omixom_data import Client, MeasurementDeleted
|
|
32
|
+
|
|
33
|
+
with Client(token="...") as client:
|
|
34
|
+
feed = client.feed([30125, 30126]) # toda la historia de cada equipo
|
|
35
|
+
|
|
36
|
+
for batch in feed.batches(): # bootstrap + cambios, hasta estar al día
|
|
37
|
+
for event in batch.events:
|
|
38
|
+
if isinstance(event, MeasurementDeleted):
|
|
39
|
+
store.delete(event.station, event.module, event.time)
|
|
40
|
+
else:
|
|
41
|
+
store.upsert(event.station, event.module, event.time, event.value)
|
|
42
|
+
save(batch.state.model_dump_json()) # checkpoint alineado a la página
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
El contrato es **aplicar primero, guardar después** (idealmente ambos en una transacción del
|
|
46
|
+
store propio). Con ese orden, cortar el proceso en cualquier punto (incluso a mitad de un
|
|
47
|
+
backfill de días) deja como peor caso una página re-aplicada, y aplicar eventos es idempotente
|
|
48
|
+
por diseño: `MeasurementUpserted` deja la copia en ese valor, `MeasurementDeleted` la quita,
|
|
49
|
+
re-aplicar no cambia nada. Cada batch persistido es progreso ganado; `break` entre batches es
|
|
50
|
+
siempre seguro.
|
|
51
|
+
|
|
52
|
+
Para volver a sincronizar (el próximo poll o la próxima corrida del proceso):
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from omixom_data import Client, FeedState
|
|
56
|
+
|
|
57
|
+
with Client(token="...") as client:
|
|
58
|
+
feed = client.resume(FeedState.model_validate_json(saved))
|
|
59
|
+
for batch in feed.batches(): # solo lo que falta desde el checkpoint
|
|
60
|
+
apply_all(batch.events)
|
|
61
|
+
save(batch.state.model_dump_json())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`batches()` es re-llamable: cada llamada avanza hasta quedar al día y la siguiente retoma desde
|
|
65
|
+
ahí, así que el loop de un daemon es `batches()` + esperar el intervalo deseado. El avance es
|
|
66
|
+
por módulo (el estado guarda un cursor por serie) y los requests van por estación: el bootstrap
|
|
67
|
+
de varios equipos avanza round-robin para solapar sus límites de uso.
|
|
68
|
+
|
|
69
|
+
## Acotar el rango
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from datetime import UTC, datetime
|
|
73
|
+
|
|
74
|
+
feed = client.feed([30125], date_from=datetime(2023, 1, 1, tzinfo=UTC)) # desde 2023
|
|
75
|
+
feed = client.feed(
|
|
76
|
+
[30125],
|
|
77
|
+
date_from=datetime(2023, 1, 1, tzinfo=UTC),
|
|
78
|
+
date_to=datetime(2024, 1, 1, tzinfo=UTC), # 2023 completo
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Sin fechas, cada equipo se replica desde su fecha de instalación. Con `date_from` solo, la
|
|
83
|
+
réplica sigue recibiendo lo nuevo; con ambas, la ventana queda cerrada pero las correcciones a
|
|
84
|
+
datos de esa ventana siguen llegando. `modules` acota a esos módulos, de cualquiera de las
|
|
85
|
+
estaciones dadas (una estación sin módulos seleccionados no genera requests); el conjunto
|
|
86
|
+
replicado queda fijado al crear el feed, así que un sensor instalado después no se suma solo
|
|
87
|
+
(ver abajo).
|
|
88
|
+
`categories` filtra qué tipo de dato replicar; con ese filtro, un punto corregido hacia una
|
|
89
|
+
categoría no seleccionada llega como borrado (salió de la vista replicada).
|
|
90
|
+
|
|
91
|
+
## Sumar módulos a un feed existente
|
|
92
|
+
|
|
93
|
+
Un sensor instalado después de crear el feed se suma con `add_modules`, sobre un feed nuevo o
|
|
94
|
+
retomado; sin lista de módulos entran todos los del equipo que falten:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
feed = client.resume(FeedState.model_validate_json(saved))
|
|
98
|
+
feed.add_modules(30125) # o add_modules(30125, [4812]) para uno puntual
|
|
99
|
+
|
|
100
|
+
for batch in feed.batches(): # el nuevo hace su bootstrap; el resto sigue donde estaba
|
|
101
|
+
apply_all(batch.events)
|
|
102
|
+
save(batch.state.model_dump_json())
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Devuelve los ids agregados (los ya rastreados se omiten) y rechaza con `ValueError` un módulo
|
|
106
|
+
que no pertenece al equipo. También sirve para sumar una estación nueva al feed. El alta queda
|
|
107
|
+
persistida con el `state` del próximo batch, así que si el proceso corta antes, la próxima corrida
|
|
108
|
+
tiene que volver a llamarlo.
|
|
109
|
+
|
|
110
|
+
## Lecturas puntuales
|
|
111
|
+
|
|
112
|
+
Para consultas de una sola vez, sin réplica:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
client.stations() # equipos accesibles con el token
|
|
116
|
+
client.station(30125) # fecha de instalación y módulos
|
|
117
|
+
|
|
118
|
+
for point in client.series( # la serie completa de una ventana, en streaming
|
|
119
|
+
30125,
|
|
120
|
+
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
|
121
|
+
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
|
122
|
+
):
|
|
123
|
+
print(point.module, point.time, point.value)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`series` camina todas las páginas por adentro repitiendo el cursor de la primera, así el
|
|
127
|
+
resultado entero es un corte coherente de la base aunque haya escrituras concurrentes. Para
|
|
128
|
+
materializar la serie, `list(client.series(...))`.
|
|
129
|
+
|
|
130
|
+
Debajo de eso está el acceso crudo página por página (`series_page`, `changes_page`), donde el
|
|
131
|
+
manejo del cursor queda a cargo del caller: para paginar de forma coherente hay que repetir el
|
|
132
|
+
request con `date_from` igual al `next_from` recibido y `cursor` igual al `cursor` recibido.
|
|
133
|
+
|
|
134
|
+
## Errores y límites de uso
|
|
135
|
+
|
|
136
|
+
Los errores de la API llegan como excepciones tipadas bajo `OmixomDataError`:
|
|
137
|
+
`AuthenticationError`, `NotFoundError`, `InvalidRequestError`, `RateLimitedError` y
|
|
138
|
+
`ServerError`. Ante un 429 el cliente espera lo que indique `Retry-After` y reintenta solo;
|
|
139
|
+
`Client(..., wait_on_rate_limit=False)` desactiva la espera y levanta `RateLimitedError` con el
|
|
140
|
+
tiempo sugerido en `retry_after`.
|
|
141
|
+
|
|
142
|
+
## Desarrollo
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uv sync
|
|
146
|
+
uv run tox # style + tests + cobertura
|
|
147
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
omixom_data/__init__.py,sha256=gQmmW7g5KJDdz_zYONWHRPUWT_BG7Fl5FYHHsE8j5Qg,1709
|
|
2
|
+
omixom_data/client.py,sha256=NcB8kk1mdA-GGu8T499E9ycz3icHs315OC_CpzGSszw,10537
|
|
3
|
+
omixom_data/errors.py,sha256=iQ3MFY7Se52Kyk0S2wzfwp9w4PfIjuxq7J2WVf6aBuc,1486
|
|
4
|
+
omixom_data/events.py,sha256=jGzROGLGr0ofIsN8-BxT5-043FBYAEgAcdsfO6KWktc,965
|
|
5
|
+
omixom_data/feed.py,sha256=6XQBMhYMDJkvVPXhTyiE3qaHlr_urs9Pnx4Whw72Jno,11423
|
|
6
|
+
omixom_data/schemas.py,sha256=tINsT67dbQrO9lFGFGE7DHxkHrpS-C7VC4oOKjWaI60,2810
|
|
7
|
+
omixom_data/state.py,sha256=EHbx-8hHT2YzZ7-dx0kvKe5Hl6b9vaLhatfTHjN9jXw,1584
|
|
8
|
+
omixom_data-0.3.0.dist-info/METADATA,sha256=1F_UpVgLQC7PJdBRkBPwzsjDKCYYdIu5gjQ-m1O0h4E,6020
|
|
9
|
+
omixom_data-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
omixom_data-0.3.0.dist-info/RECORD,,
|