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