diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be4511..0a4db1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * Removed the option `raise_on_max_attempts` from `Retrier`. If the number of attempts is reached, the retrier will always raise an exception. +### 🚀 New + +* Add `get_weather` function to retrieve weather data from the LCO API (via `lvmapi`). + ### ✨ Improved * Better typing for `Retrier.__call__()`. @@ -16,11 +20,6 @@ * Fix some unittests. -### 🚀 New - -* Add `get_weather` function to retrieve weather data from the LCO API (via `lvmapi`). - - ## 0.3.9 - September 17, 2024 ### 🔧 Fixed diff --git a/pyproject.toml b/pyproject.toml index dc4e108..972f251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lvmopstools" -version = "0.3.10a0" +version = "0.4.0" description = "LVM tools and utilities for operations" authors = [ { name = "José Sánchez-Gallego", email = "gallegoj@uw.edu" } @@ -21,6 +21,8 @@ dependencies = [ [project.optional-dependencies] ds9 = [ "pyds9>=1.8.1" ] +kubernetes = [ "kubernetes>=31.0.0" ] +influxdb = [ "influxdb-client>=1.47.0" ] [project.urls] Homepage = "https://github.com/sdss/lvmopstools" @@ -87,6 +89,8 @@ omit = [ "src/lvmopstools/__main__.py", "src/lvmopstools/clu.py", "src/lvmopstools/ds9.py", + "src/lvmopstools/kubernetes.py", + "src/lvmopstools/influxdb.py", "src/lvmopstools/utils.py", "src/lvmopstools/devices/specs.py", "src/lvmopstools/devices/nps.py" diff --git a/src/lvmopstools/__init__.py b/src/lvmopstools/__init__.py index 868c537..08b251b 100644 --- a/src/lvmopstools/__init__.py +++ b/src/lvmopstools/__init__.py @@ -49,5 +49,5 @@ def set_config(config_file: str | pathlib.Path | None = None) -> None: CONFIG_FILE = config_path -from .retrier import * -from .socket import * +from .retrier import Retrier +from .socket import AsyncSocketHandler diff --git a/src/lvmopstools/influxdb.py b/src/lvmopstools/influxdb.py new file mode 100644 index 0000000..f50e5d3 --- /dev/null +++ b/src/lvmopstools/influxdb.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2023-11-19 +# @Filename: influxdb.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +# + +from __future__ import annotations + +import json +import os +import warnings + +import polars + + +try: + from influxdb_client.client.influxdb_client_async import InfluxDBClientAsync + from influxdb_client.client.warnings import MissingPivotFunction +except ImportError: + InfluxDBClientAsync = None + MissingPivotFunction = None + + +__all__ = ["query_influxdb"] + + +async def query_influxdb( + url: str, + query: str, + token: str | None = None, + org: str | None = None, +) -> polars.DataFrame: + """Runs a query in InfluxDB and returns a Polars dataframe.""" + + if not InfluxDBClientAsync or not MissingPivotFunction: + raise ImportError("influxdb-client is not installed. Use the influxdb extra.") + + warnings.simplefilter("ignore", MissingPivotFunction) # noqa: F821 + + token = token or os.environ.get("INFLUXDB_V2_TOKEN") + if token is None: + raise ValueError("$INFLUXDB_V2_TOKEN not defined.") + + org = org or os.environ.get("INFLUXDB_V2_ORG") + if org is None: + raise ValueError("$INFLUXDB_V2_ORG not defined.") + + async with InfluxDBClientAsync(url=url, token=token, org=org) as client: + if not (await client.ping()): + raise RuntimeError("InfluxDB client failed to connect.") + + api = client.query_api() + + query_results = await api.query(query) + + df = polars.DataFrame(json.loads(query_results.to_json())) + + if len(df) > 0: + df = df.with_columns(polars.col._time.cast(polars.Datetime("ms"))) + + return df diff --git a/src/lvmopstools/kubernetes.py b/src/lvmopstools/kubernetes.py new file mode 100644 index 0000000..01e29aa --- /dev/null +++ b/src/lvmopstools/kubernetes.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2023-06-29 +# @Filename: kubernetes.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import asyncio +import datetime +import os +import warnings +from pathlib import Path + +from lvmopstools.utils import is_notebook + + +try: + import kubernetes + from kubernetes.utils import create_from_yaml +except ImportError: + kubernetes = None + create_from_yaml = None + + +class Kubernetes: + """Interface with the Kubernetes cluster.""" + + def __init__(self, deployments_path: str | Path | None = None): + if kubernetes is None: + raise ImportError("kubernetes is not installed. Use the kubernetes extra.") + + self.is_notebook = is_notebook() + + if os.getenv("KUBERNETES_SERVICE_HOST"): + self.is_pod = True + else: + self.is_pod = False + + # If we are in a notebook, we assume it's the one running in the Jupyter + # Lab deployment, which is configured to have access to the cluster. + if self.is_notebook or self.is_pod: + kubernetes.config.load_incluster_config() + else: + kubernetes.config.load_config() + + self.v1 = kubernetes.client.CoreV1Api() + self.apps_v1 = kubernetes.client.AppsV1Api() + + self.deployments_path = Path(deployments_path) if deployments_path else None + + def list_namespaces(self): + """Returns a list of namespaces.""" + + namespace_info = self.v1.list_namespace() + namespaces = [item.metadata.name for item in namespace_info.items] + + return namespaces + + def list_deployments(self): + """Returns a list of deployments.""" + + deployment_info = self.apps_v1.list_deployment_for_all_namespaces() + deployments = [item.metadata.name for item in deployment_info.items] + + return deployments + + def get_deployment_info(self, deployment: str): + """Returns the deployment info for a deployment.""" + + deployment_info = self.apps_v1.list_deployment_for_all_namespaces() + + for item in deployment_info.items: + meta = item.metadata + if meta.name == deployment: + return item.to_dict() + + raise ValueError(f"Deployment {deployment!r} not found.") + + def get_deployment_namespace(self, deployment: str): + """Returns the namespace of a deployment.""" + + deployment_info = self.apps_v1.list_deployment_for_all_namespaces() + + for item in deployment_info.items: + meta = item.metadata + if meta.name == deployment: + return meta.namespace + + return None + + def get_yaml_file(self, name: str): + """Finds and returns the contents of a Kubernetes YAML file.""" + + if not self.deployments_path: + raise ValueError("No deployments path defined.") + + files = list(self.deployments_path.glob(f"**/{name}.y*ml")) + + if files is None or len(files) == 0: + raise ValueError(f"No YAML file found for {name!r}.") + elif len(files) > 1: + raise ValueError(f"Multiple YAML files found for {name!r}.") + + return files[0] + + def apply_from_file(self, name: str | Path): + """Applies a YAML file. + + Parameters + ---------- + name + The full path to the file to apply. If the path is relative, + the file will be searched in the directory for YAML files + defined in the configuration. + + """ + + if create_from_yaml is None or kubernetes is None: + raise ImportError("kubernetes is not installed. Use the kubernetes extra.") + + if isinstance(name, Path) or os.path.isabs(name): + path = Path(name) + else: + path = self.get_yaml_file(name) + + deployments = create_from_yaml( + kubernetes.client.ApiClient(), + yaml_file=str(path), + ) + + return [dep[0].metadata.name for dep in deployments] + + def delete_deployment(self, deployment: str): + """Deletes resources from a YAML file. + + Parameters + ---------- + deployment + The deployment to delete. + + """ + + namespace = self.get_deployment_namespace(deployment) + if namespace is None: + raise ValueError(f"Deployment {deployment!r} not found.") + + self.apps_v1.delete_namespaced_deployment(deployment, namespace) + + async def restart_deployment(self, deployment: str, from_file: bool = True): + """Restarts a deployment. + + If the deployment is running, does a rollout restart. Otherwise looks + for the deployment file and applies it. + + """ + + if deployment in self.list_deployments() and not from_file: + namespace = self.get_deployment_namespace(deployment) + if namespace is None: + raise ValueError(f"Namespace not found for deployment {deployment}.") + + # Create a patch for the current deployment saying that + # it was restarted now, and it will. + now = datetime.datetime.now(datetime.timezone.utc) + now = str(now.isoformat("T") + "Z") + body = { + "spec": { + "template": { + "metadata": { + "annotations": {"kubectl.kubernetes.io/restartedAt": now} + } + } + } + } + + self.apps_v1.patch_namespaced_deployment( + deployment, + namespace, + body, + pretty="true", + ) + + else: + try: + file_ = self.get_yaml_file(deployment) + except ValueError as err: + raise RuntimeError(f"Failed restarting from file: {err} ") + + if deployment in self.list_deployments(): + self.delete_deployment(deployment) + await asyncio.sleep(3) # Give some time for the pods to exit. + else: + warnings.warn(f"{deployment!r} is not running.") + + self.apply_from_file(file_) diff --git a/src/lvmopstools/utils.py b/src/lvmopstools/utils.py index 10aa5ab..e400aa2 100644 --- a/src/lvmopstools/utils.py +++ b/src/lvmopstools/utils.py @@ -74,3 +74,22 @@ async def stop_event_loop(timeout: float | None = 5): pass finally: asyncio.get_running_loop().stop() + + +def is_notebook() -> bool: + """Returns :obj:`True` if the code is run inside a Jupyter Notebook. + + https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook + + """ + + try: + shell = get_ipython().__class__.__name__ # type: ignore + if shell == "ZMQInteractiveShell": + return True # Jupyter notebook or qtconsole + elif shell == "TerminalInteractiveShell": + return False # Terminal running IPython + else: + return False # Other type (?) + except NameError: + return False # Probably standard Python interpreter diff --git a/src/lvmopstools/weather.py b/src/lvmopstools/weather.py index c1631fd..4388b69 100644 --- a/src/lvmopstools/weather.py +++ b/src/lvmopstools/weather.py @@ -2,22 +2,81 @@ # -*- coding: utf-8 -*- # # @Author: José Sánchez-Gallego (gallegoj@uw.edu) -# @Date: 2024-11-12 +# @Date: 2024-03-26 # @Filename: weather.py # @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) +from __future__ import annotations + import datetime +import time import httpx import polars -from lvmopstools import config + +__all__ = ["get_weather_data", "is_weather_data_safe"] + + +WEATHER_URL = "http://dataservice.lco.cl/vaisala/data" -async def get_weather( - start_time: float | str, - end_time: float | str, -) -> polars.DataFrame: +def format_time(time: str | float) -> str: + """Formats a time string for the LCO weather API format""" + + if isinstance(time, (float, int)): + time = ( + datetime.datetime.fromtimestamp( + time, + tz=datetime.timezone.utc, + ) + .isoformat(timespec="seconds") + .replace("+00:00", "") + ) + + if "T" in time: + time = time.replace("T", " ") + if "." in time: + time = time.split(".")[0] + + return time + + +async def get_from_lco_api( + start_time: str, + end_time: str, + station: str, +): # pragma: no cover + """Queries the LCO API for weather data.""" + + async with httpx.AsyncClient() as client: + response = await client.get( + WEATHER_URL, + params={ + "start_ts": start_time, + "end_ts": end_time, + "station": station, + }, + ) + + if response.status_code != 200: + raise ValueError(f"Failed to get weather data: {response.text}") + + data = response.json() + + if "Error" in data: + raise ValueError(f"Failed to get weather data: {data['Error']}") + elif "results" not in data or data["results"] is None: + raise ValueError("Failed to get weather data: no results found.") + + return data["results"] + + +async def get_weather_data( + start_time: str | float, + end_time: str | float | None = None, + station="DuPont", +): """Returns a data frame with weather data from the du Pont station. .. warning:: @@ -30,6 +89,9 @@ async def get_weather( The start time of the query. Can be a UNIX timestamp or an ISO datetime string. end_time The end time of the query. Can be a UNIX timestamp or an ISO datetime string. + Defaults to the current time. + station + The station to query. Must be one of 'DuPont', 'C40', or 'Magellan'. Returns ------- @@ -38,40 +100,136 @@ async def get_weather( """ - base_url: str = config["api"] + if station not in ["DuPont", "C40", "Magellan"]: + raise ValueError("station must be one of 'DuPont', 'C40', or 'Magellan'.") - if isinstance(start_time, (float, int)): - start_time = ( - datetime.datetime.fromtimestamp( - start_time, - tz=datetime.UTC, - ) - .isoformat() - .replace("+00:00", "Z") - ) + start_time = format_time(start_time) + end_time = format_time(end_time or time.time()) - if isinstance(end_time, (float, int)): - end_time = ( - datetime.datetime.fromtimestamp( - end_time, - tz=datetime.UTC, + results = await get_from_lco_api(start_time, end_time, station) + + df = polars.DataFrame(results) + df = df.with_columns( + ts=polars.col("ts").str.to_datetime(time_unit="ms", time_zone="UTC"), + station=polars.lit(station, polars.String), + ) + + # Delete rows with all null values. + df = df.filter(~polars.all_horizontal(polars.exclude("ts", "station").is_null())) + + # Sort by timestamp + df = df.sort("ts") + + # Convert wind speeds to mph (the LCO API returns km/h) + df = df.with_columns(polars.selectors.starts_with("wind_") / 1.60934) + + # Calculate rolling means for average wind speed and gusts every 5m, 10m, 30m + window_sizes = ["5m", "10m", "30m"] + df = df.with_columns( + **{ + f"wind_speed_avg_{ws}": polars.col.wind_speed_avg.rolling_mean_by( + by="ts", + window_size=ws, ) - .isoformat() - .replace("+00:00", "Z") - ) + for ws in window_sizes + }, + **{ + f"wind_gust_{ws}": polars.col.wind_speed_max.rolling_max_by( + by="ts", + window_size=ws, + ) + for ws in window_sizes + }, + **{ + f"wind_dir_avg_{ws}": polars.col.wind_dir_avg.rolling_mean_by( + by="ts", + window_size=ws, + ) + for ws in window_sizes + }, + ) - async with httpx.AsyncClient(base_url=base_url, follow_redirects=True) as client: - response = await client.get( - f"/weather/report?start_time={start_time}&end_time={end_time}" - ) - response.raise_for_status() + # Add simple dew point. + df = df.with_columns( + dew_point=polars.col.temperature + - ((100 - polars.col.relative_humidity) / 5.0).round(2) + ) + + # Change float precision to f32 + df = df.with_columns(polars.selectors.float().cast(polars.Float32)) + + return df + + +def is_weather_data_safe( + data: polars.DataFrame, + measurement: str, + threshold: float, + window: int = 30, + rolling_average_window: int = 10, + reopen_value: float | None = None, +): + """Determines whether an alert should be raised for a given weather measurement. + + An alert will be issued if the rolling average value of the ``measurement`` + (a column in ``data``) over the last ``window`` minutes is above the + ``threshold``. Once the alert has been raised the value of the ``measurement`` + must fall below the ``reopen_value`` to close the alert (defaults to the same + ``threshold`` value) in a rolling. + + ``window`` and ``rolling_average_window`` are in minutes. + + Examples + -------- + >>> is_weather_data_safe(data, "wind_speed_avg", 35) + True + + Returns + ------- + result + A boolean indicating whether the measurement is safe. `True` means the + measurement is in a valid, safe range. + + """ + + if measurement not in data.columns: + raise ValueError(f"Measurement {measurement} not found in data.") + + reopen_value = reopen_value or threshold + + data = data.select(polars.col.ts, polars.col(measurement)) + data = data.filter(~polars.all_horizontal(polars.exclude("ts").is_null())) + data = data.with_columns( + timestamp=polars.col.ts.dt.timestamp("ms") / 1000, + average=polars.col(measurement).rolling_mean_by( + by="ts", + window_size=f"{rolling_average_window}m", + ), + ) + + # Get data from the last window`. + now = time.time() + data_window = data.filter(polars.col.timestamp > (now - window * 60)) + + # If any of the values in the last "window" is above the threshold, it's unsafe. + if (data_window["average"] >= threshold).any(): + return False + + # If all the values in the last "window" are below the reopen threshold, it's safe. + if (data_window["average"] < reopen_value).all(): + return True - data_json = response.json() + # The last case is if the values in the last "window" are between the reopen and + # the threshold values. We want to avoid the returned value flipping from true + # to false in a quick manner. We check the previous "window" minutes to see if + # the alert was raised at any point. If so, we require the current window to + # be below the reopen value. Otherwise, we consider it's safe. - data = ( - polars.DataFrame(data_json, orient="row") - .with_columns(ts=polars.col.ts.str.to_datetime(time_unit="ms", time_zone="UTC")) - .rename({"ts": "time"}) + prev_window = data.filter( + polars.col.timestamp > (now - 2 * window * 60), + polars.col.timestamp < (now - window * 60), ) + if (prev_window["average"] >= threshold).any(): + return False - return data + return True diff --git a/tests/conftest.py b/tests/conftest.py index 43202ac..93149b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ async def _troubleshoot_internal( @pytest.fixture(scope="session", autouse=True) def monkeypatch_config(): - set_config(pathlib.Path(__file__).parent / "test_config.yaml") + set_config(pathlib.Path(__file__).parent / "data" / "test_config.yaml") @pytest.fixture() diff --git a/tests/test_config.yaml b/tests/data/test_config.yaml similarity index 100% rename from tests/test_config.yaml rename to tests/data/test_config.yaml diff --git a/tests/data/weather_response.json b/tests/data/weather_response.json new file mode 100644 index 0000000..52f251c --- /dev/null +++ b/tests/data/weather_response.json @@ -0,0 +1,1289 @@ +[ + { + "ts": "2024-11-27T04:14:14.704296", + "temperature": 14.666, + "wind_dir_min": 333.0, + "wind_dir_avg": 1.0, + "wind_dir_max": 20.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 19.147, + "wind_speed_max": 30.731, + "relative_humidity": 25.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:13:10.882308", + "temperature": 14.777, + "wind_dir_min": 328.0, + "wind_dir_avg": 353.0, + "wind_dir_max": 15.0, + "wind_speed_min": 10.297, + "wind_speed_avg": 14.963, + "wind_speed_max": 20.273, + "relative_humidity": 26.0, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:12:07.384744", + "temperature": null, + "wind_dir_min": null, + "wind_dir_avg": null, + "wind_dir_max": null, + "wind_speed_min": null, + "wind_speed_avg": null, + "wind_speed_max": null, + "relative_humidity": null, + "air_pressure": null, + "rain_intensity": null + }, + { + "ts": "2024-11-27T04:11:04.101614", + "temperature": 14.777, + "wind_dir_min": 322.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 11.0, + "wind_speed_min": 9.814, + "wind_speed_avg": 13.032, + "wind_speed_max": 16.25, + "relative_humidity": 19.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:10:00.281663", + "temperature": 14.888, + "wind_dir_min": 313.0, + "wind_dir_avg": 349.0, + "wind_dir_max": 11.0, + "wind_speed_min": 10.297, + "wind_speed_avg": 13.193, + "wind_speed_max": 16.733, + "relative_humidity": 25.1, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:08:56.460920", + "temperature": 14.888, + "wind_dir_min": 224.0, + "wind_dir_avg": 346.0, + "wind_dir_max": 19.0, + "wind_speed_min": 9.01, + "wind_speed_avg": 15.768, + "wind_speed_max": 24.617, + "relative_humidity": 21.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:07:52.639483", + "temperature": 14.888, + "wind_dir_min": 328.0, + "wind_dir_avg": 349.0, + "wind_dir_max": 1.0, + "wind_speed_min": 11.584, + "wind_speed_avg": 13.837, + "wind_speed_max": 15.768, + "relative_humidity": 19.4, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:06:48.820842", + "temperature": 14.888, + "wind_dir_min": 316.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 12.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 14.963, + "wind_speed_max": 26.548, + "relative_humidity": 18.5, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:05:44.999581", + "temperature": 14.888, + "wind_dir_min": 321.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 8.0, + "wind_speed_min": 9.01, + "wind_speed_avg": 13.354, + "wind_speed_max": 17.699, + "relative_humidity": 22.2, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:04:41.181328", + "temperature": 14.888, + "wind_dir_min": 323.0, + "wind_dir_avg": 351.0, + "wind_dir_max": 14.0, + "wind_speed_min": 10.941, + "wind_speed_avg": 13.998, + "wind_speed_max": 21.882, + "relative_humidity": 22.5, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:03:37.359424", + "temperature": 14.888, + "wind_dir_min": 288.0, + "wind_dir_avg": 349.0, + "wind_dir_max": 1.0, + "wind_speed_min": 8.045, + "wind_speed_avg": 13.354, + "wind_speed_max": 15.124, + "relative_humidity": 20.4, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:02:33.538910", + "temperature": 14.777, + "wind_dir_min": 321.0, + "wind_dir_avg": 350.0, + "wind_dir_max": 19.0, + "wind_speed_min": 10.941, + "wind_speed_avg": 13.837, + "wind_speed_max": 20.434, + "relative_humidity": 20.3, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:01:29.724501", + "temperature": 14.666, + "wind_dir_min": 330.0, + "wind_dir_avg": 357.0, + "wind_dir_max": 19.0, + "wind_speed_min": 11.263, + "wind_speed_avg": 18.02, + "wind_speed_max": 30.571, + "relative_humidity": 23.6, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T04:00:25.899184", + "temperature": 14.777, + "wind_dir_min": 318.0, + "wind_dir_avg": 357.0, + "wind_dir_max": 20.0, + "wind_speed_min": 6.436, + "wind_speed_avg": 18.825, + "wind_speed_max": 31.375, + "relative_humidity": 26.1, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:59:22.078468", + "temperature": 14.777, + "wind_dir_min": 289.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 13.0, + "wind_speed_min": 7.079, + "wind_speed_avg": 13.998, + "wind_speed_max": 21.882, + "relative_humidity": 25.3, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:58:18.260148", + "temperature": 14.777, + "wind_dir_min": 321.0, + "wind_dir_avg": 1.0, + "wind_dir_max": 23.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 20.434, + "wind_speed_max": 32.501, + "relative_humidity": 24.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:57:14.438750", + "temperature": 14.777, + "wind_dir_min": 326.0, + "wind_dir_avg": 349.0, + "wind_dir_max": 18.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 15.285, + "wind_speed_max": 28.962, + "relative_humidity": 23.1, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:56:10.618329", + "temperature": 14.777, + "wind_dir_min": 319.0, + "wind_dir_avg": 343.0, + "wind_dir_max": 16.0, + "wind_speed_min": 9.814, + "wind_speed_avg": 12.711, + "wind_speed_max": 24.456, + "relative_humidity": 26.2, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:55:07.126954", + "temperature": null, + "wind_dir_min": null, + "wind_dir_avg": null, + "wind_dir_max": null, + "wind_speed_min": null, + "wind_speed_avg": null, + "wind_speed_max": null, + "relative_humidity": null, + "air_pressure": null, + "rain_intensity": null + }, + { + "ts": "2024-11-27T03:54:03.837791", + "temperature": 14.777, + "wind_dir_min": 322.0, + "wind_dir_avg": 344.0, + "wind_dir_max": 9.0, + "wind_speed_min": 8.205, + "wind_speed_avg": 12.228, + "wind_speed_max": 18.342, + "relative_humidity": 24.6, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:53:00.017746", + "temperature": 14.888, + "wind_dir_min": 319.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 17.0, + "wind_speed_min": 9.975, + "wind_speed_avg": 14.159, + "wind_speed_max": 24.939, + "relative_humidity": 25.1, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:51:56.198062", + "temperature": 14.888, + "wind_dir_min": 320.0, + "wind_dir_avg": 345.0, + "wind_dir_max": 19.0, + "wind_speed_min": 7.562, + "wind_speed_avg": 12.55, + "wind_speed_max": 20.756, + "relative_humidity": 25.0, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:50:52.375905", + "temperature": 14.777, + "wind_dir_min": 328.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 3.0, + "wind_speed_min": 11.263, + "wind_speed_avg": 13.032, + "wind_speed_max": 14.963, + "relative_humidity": 23.5, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:49:48.557037", + "temperature": 14.777, + "wind_dir_min": 319.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 359.0, + "wind_speed_min": 9.654, + "wind_speed_avg": 13.354, + "wind_speed_max": 21.56, + "relative_humidity": 23.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:48:44.736690", + "temperature": 14.777, + "wind_dir_min": 320.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 7.0, + "wind_speed_min": 11.906, + "wind_speed_avg": 13.998, + "wind_speed_max": 24.456, + "relative_humidity": 23.2, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:47:40.917636", + "temperature": 14.777, + "wind_dir_min": 321.0, + "wind_dir_avg": 343.0, + "wind_dir_max": 3.0, + "wind_speed_min": 11.423, + "wind_speed_avg": 13.837, + "wind_speed_max": 19.468, + "relative_humidity": 24.8, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:46:37.097304", + "temperature": 14.888, + "wind_dir_min": 319.0, + "wind_dir_avg": 345.0, + "wind_dir_max": 11.0, + "wind_speed_min": 6.918, + "wind_speed_avg": 12.389, + "wind_speed_max": 20.756, + "relative_humidity": 27.4, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:45:33.236501", + "temperature": 14.888, + "wind_dir_min": 321.0, + "wind_dir_avg": 343.0, + "wind_dir_max": 4.0, + "wind_speed_min": 10.458, + "wind_speed_avg": 12.5, + "wind_speed_max": 18.02, + "relative_humidity": 25.5, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:44:29.415822", + "temperature": 14.888, + "wind_dir_min": 323.0, + "wind_dir_avg": 346.0, + "wind_dir_max": 2.0, + "wind_speed_min": 10.297, + "wind_speed_avg": 12.5, + "wind_speed_max": 18.342, + "relative_humidity": 24.6, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:43:25.597219", + "temperature": 14.888, + "wind_dir_min": 320.0, + "wind_dir_avg": 344.0, + "wind_dir_max": 7.0, + "wind_speed_min": 9.814, + "wind_speed_avg": 12.5, + "wind_speed_max": 20.434, + "relative_humidity": 23.3, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:42:21.775139", + "temperature": 14.888, + "wind_dir_min": 321.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 4.0, + "wind_speed_min": 11.584, + "wind_speed_avg": 12.5, + "wind_speed_max": 19.629, + "relative_humidity": 25.5, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:41:17.959613", + "temperature": 15.0, + "wind_dir_min": 325.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 3.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 12.5, + "wind_speed_max": 15.929, + "relative_humidity": 24.2, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:40:14.095645", + "temperature": 15.0, + "wind_dir_min": 321.0, + "wind_dir_avg": 348.0, + "wind_dir_max": 16.0, + "wind_speed_min": 10.941, + "wind_speed_avg": 19.5, + "wind_speed_max": 27.674, + "relative_humidity": 21.2, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:39:10.275642", + "temperature": 15.055, + "wind_dir_min": 329.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 5.0, + "wind_speed_min": 10.297, + "wind_speed_avg": 19.5, + "wind_speed_max": 17.216, + "relative_humidity": 25.7, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:38:06.797403", + "temperature": null, + "wind_dir_min": null, + "wind_dir_avg": null, + "wind_dir_max": null, + "wind_speed_min": null, + "wind_speed_avg": null, + "wind_speed_max": null, + "relative_humidity": null, + "air_pressure": null, + "rain_intensity": null + }, + { + "ts": "2024-11-27T03:37:03.494995", + "temperature": 15.277, + "wind_dir_min": 321.0, + "wind_dir_avg": 336.0, + "wind_dir_max": 353.0, + "wind_speed_min": 13.193, + "wind_speed_avg": 19.5, + "wind_speed_max": 19.79, + "relative_humidity": 18.7, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:35:59.675031", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 358.0, + "wind_speed_min": 14.963, + "wind_speed_avg": 19.5, + "wind_speed_max": 25.261, + "relative_humidity": 16.9, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:34:55.855651", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 336.0, + "wind_dir_max": 7.0, + "wind_speed_min": 14.802, + "wind_speed_avg": 19.5, + "wind_speed_max": 27.031, + "relative_humidity": 18.9, + "air_pressure": 22.9, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:33:52.034655", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 1.0, + "wind_speed_min": 16.09, + "wind_speed_avg": 19.5, + "wind_speed_max": 26.387, + "relative_humidity": 14.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:32:48.216817", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 341.0, + "wind_dir_max": 7.0, + "wind_speed_min": 18.02, + "wind_speed_avg": 19.5, + "wind_speed_max": 28.64, + "relative_humidity": 15.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:31:44.395632", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 7.0, + "wind_speed_min": 17.055, + "wind_speed_avg": 19.5, + "wind_speed_max": 25.904, + "relative_humidity": 15.8, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:30:40.574058", + "temperature": 15.388, + "wind_dir_min": 319.0, + "wind_dir_avg": 341.0, + "wind_dir_max": 8.0, + "wind_speed_min": 15.929, + "wind_speed_avg": 19.5, + "wind_speed_max": 27.835, + "relative_humidity": 17.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:29:36.754706", + "temperature": 15.388, + "wind_dir_min": 319.0, + "wind_dir_avg": 344.0, + "wind_dir_max": 7.0, + "wind_speed_min": 16.733, + "wind_speed_avg": 19.5, + "wind_speed_max": 28.479, + "relative_humidity": 17.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:28:32.934781", + "temperature": 15.388, + "wind_dir_min": 320.0, + "wind_dir_avg": 341.0, + "wind_dir_max": 5.0, + "wind_speed_min": 15.768, + "wind_speed_avg": 19.5, + "wind_speed_max": 28.318, + "relative_humidity": 15.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:27:29.114490", + "temperature": 15.277, + "wind_dir_min": 320.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 6.0, + "wind_speed_min": 15.607, + "wind_speed_avg": 19.5, + "wind_speed_max": 27.996, + "relative_humidity": 15.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:26:25.293633", + "temperature": 15.277, + "wind_dir_min": 320.0, + "wind_dir_avg": 337.0, + "wind_dir_max": 8.0, + "wind_speed_min": 15.446, + "wind_speed_avg": 19.5, + "wind_speed_max": 26.387, + "relative_humidity": 20.1, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:25:21.473536", + "temperature": 15.277, + "wind_dir_min": 320.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 5.0, + "wind_speed_min": 14.963, + "wind_speed_avg": 19.5, + "wind_speed_max": 27.835, + "relative_humidity": 19.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:24:17.653434", + "temperature": 15.166, + "wind_dir_min": 320.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 9.0, + "wind_speed_min": 14.802, + "wind_speed_avg": 19.5, + "wind_speed_max": 26.065, + "relative_humidity": 19.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:23:13.832893", + "temperature": 15.055, + "wind_dir_min": 320.0, + "wind_dir_avg": 342.0, + "wind_dir_max": 10.0, + "wind_speed_min": 13.998, + "wind_speed_avg": 19.5, + "wind_speed_max": 26.226, + "relative_humidity": 18.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:22:09.977164", + "temperature": 15.055, + "wind_dir_min": 320.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 7.0, + "wind_speed_min": 14.481, + "wind_speed_avg": 19.5, + "wind_speed_max": 23.33, + "relative_humidity": 25.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:21:06.152706", + "temperature": 15.055, + "wind_dir_min": 298.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 9.0, + "wind_speed_min": 11.745, + "wind_speed_avg": 19.5, + "wind_speed_max": 25.904, + "relative_humidity": 22.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:20:02.291673", + "temperature": 15.055, + "wind_dir_min": 312.0, + "wind_dir_avg": 336.0, + "wind_dir_max": 357.0, + "wind_speed_min": 12.228, + "wind_speed_avg": 19.5, + "wind_speed_max": 22.365, + "relative_humidity": 25.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:18:58.473442", + "temperature": 15.055, + "wind_dir_min": 315.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 0.0, + "wind_speed_min": 11.263, + "wind_speed_avg": 19.5, + "wind_speed_max": 23.813, + "relative_humidity": 26.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:17:54.652169", + "temperature": 15.055, + "wind_dir_min": 320.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 7.0, + "wind_speed_min": 12.872, + "wind_speed_avg": 19.5, + "wind_speed_max": 24.778, + "relative_humidity": 25.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:16:50.792653", + "temperature": 15.055, + "wind_dir_min": 315.0, + "wind_dir_avg": 338.0, + "wind_dir_max": 0.0, + "wind_speed_min": 10.619, + "wind_speed_avg": 19.5, + "wind_speed_max": 18.503, + "relative_humidity": 24.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:15:46.975822", + "temperature": 15.055, + "wind_dir_min": 293.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 16.0, + "wind_speed_min": 11.745, + "wind_speed_avg": 19.5, + "wind_speed_max": 22.847, + "relative_humidity": 25.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:14:43.151321", + "temperature": 15.055, + "wind_dir_min": 319.0, + "wind_dir_avg": 341.0, + "wind_dir_max": 11.0, + "wind_speed_min": 10.136, + "wind_speed_avg": 19.5, + "wind_speed_max": 26.387, + "relative_humidity": 27.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:13:39.330138", + "temperature": 15.0, + "wind_dir_min": 300.0, + "wind_dir_avg": 337.0, + "wind_dir_max": 4.0, + "wind_speed_min": 9.493, + "wind_speed_avg": 19.5, + "wind_speed_max": 20.756, + "relative_humidity": 27.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:12:35.511301", + "temperature": 15.055, + "wind_dir_min": 298.0, + "wind_dir_avg": 334.0, + "wind_dir_max": 19.0, + "wind_speed_min": 8.527, + "wind_speed_avg": 19.5, + "wind_speed_max": 18.342, + "relative_humidity": 29.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:11:31.690206", + "temperature": 15.0, + "wind_dir_min": 320.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 2.0, + "wind_speed_min": 9.975, + "wind_speed_avg": 19.5, + "wind_speed_max": 19.468, + "relative_humidity": 28.1, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:10:27.869559", + "temperature": 15.055, + "wind_dir_min": 315.0, + "wind_dir_avg": 338.0, + "wind_dir_max": 359.0, + "wind_speed_min": 8.527, + "wind_speed_avg": 19.5, + "wind_speed_max": 14.963, + "relative_humidity": 29.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:09:24.049579", + "temperature": 15.0, + "wind_dir_min": 319.0, + "wind_dir_avg": 342.0, + "wind_dir_max": 19.0, + "wind_speed_min": 9.171, + "wind_speed_avg": 19.5, + "wind_speed_max": 24.939, + "relative_humidity": 29.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:08:20.189363", + "temperature": 15.055, + "wind_dir_min": 315.0, + "wind_dir_avg": 339.0, + "wind_dir_max": 2.0, + "wind_speed_min": 8.849, + "wind_speed_avg": 19.5, + "wind_speed_max": 16.894, + "relative_humidity": 29.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:07:16.369031", + "temperature": 15.0, + "wind_dir_min": 302.0, + "wind_dir_avg": 338.0, + "wind_dir_max": 5.0, + "wind_speed_min": 8.366, + "wind_speed_avg": 19.5, + "wind_speed_max": 14.802, + "relative_humidity": 29.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:06:12.509644", + "temperature": 15.055, + "wind_dir_min": 284.0, + "wind_dir_avg": 331.0, + "wind_dir_max": 11.0, + "wind_speed_min": 7.401, + "wind_speed_avg": 19.5, + "wind_speed_max": 19.147, + "relative_humidity": 30.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:05:08.689047", + "temperature": 15.055, + "wind_dir_min": 288.0, + "wind_dir_avg": 334.0, + "wind_dir_max": 19.0, + "wind_speed_min": 7.24, + "wind_speed_avg": 19.5, + "wind_speed_max": 30.892, + "relative_humidity": 29.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:04:04.859620", + "temperature": 15.055, + "wind_dir_min": 318.0, + "wind_dir_avg": 340.0, + "wind_dir_max": 10.0, + "wind_speed_min": 11.263, + "wind_speed_avg": 19.5, + "wind_speed_max": 24.135, + "relative_humidity": 30.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:03:01.002858", + "temperature": 15.055, + "wind_dir_min": 287.0, + "wind_dir_avg": 338.0, + "wind_dir_max": 0.0, + "wind_speed_min": 8.527, + "wind_speed_avg": 19.5, + "wind_speed_max": 22.686, + "relative_humidity": 30.4, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:01:57.179178", + "temperature": 15.055, + "wind_dir_min": 290.0, + "wind_dir_avg": 335.0, + "wind_dir_max": 12.0, + "wind_speed_min": 8.205, + "wind_speed_avg": 19.5, + "wind_speed_max": 20.756, + "relative_humidity": 30.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T03:00:53.358469", + "temperature": 15.055, + "wind_dir_min": 251.0, + "wind_dir_avg": 314.0, + "wind_dir_max": 5.0, + "wind_speed_min": 6.757, + "wind_speed_avg": 19.5, + "wind_speed_max": 21.077, + "relative_humidity": 31.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:59:49.537622", + "temperature": 15.055, + "wind_dir_min": 311.0, + "wind_dir_avg": 338.0, + "wind_dir_max": 18.0, + "wind_speed_min": 9.171, + "wind_speed_avg": 19.5, + "wind_speed_max": 14.481, + "relative_humidity": 30.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:58:45.724549", + "temperature": 15.055, + "wind_dir_min": 319.0, + "wind_dir_avg": 354.0, + "wind_dir_max": 20.0, + "wind_speed_min": 8.205, + "wind_speed_avg": 19.5, + "wind_speed_max": 31.375, + "relative_humidity": 29.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:57:41.858369", + "temperature": 15.166, + "wind_dir_min": 333.0, + "wind_dir_avg": 8.0, + "wind_dir_max": 22.0, + "wind_speed_min": 10.458, + "wind_speed_avg": 19.5, + "wind_speed_max": 31.858, + "relative_humidity": 31.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:56:38.038260", + "temperature": 15.166, + "wind_dir_min": 285.0, + "wind_dir_avg": 3.0, + "wind_dir_max": 24.0, + "wind_speed_min": 7.079, + "wind_speed_avg": 19.5, + "wind_speed_max": 31.697, + "relative_humidity": 30.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:55:34.219959", + "temperature": 15.166, + "wind_dir_min": 298.0, + "wind_dir_avg": 349.0, + "wind_dir_max": 21.0, + "wind_speed_min": 6.596, + "wind_speed_avg": 19.5, + "wind_speed_max": 32.019, + "relative_humidity": 30.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:54:30.399514", + "temperature": 15.166, + "wind_dir_min": 320.0, + "wind_dir_avg": 3.0, + "wind_dir_max": 22.0, + "wind_speed_min": 9.814, + "wind_speed_avg": 19.5, + "wind_speed_max": 31.214, + "relative_humidity": 30.7, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:53:26.578140", + "temperature": 15.166, + "wind_dir_min": 332.0, + "wind_dir_avg": 14.0, + "wind_dir_max": 20.0, + "wind_speed_min": 12.55, + "wind_speed_avg": 19.5, + "wind_speed_max": 32.18, + "relative_humidity": 30.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:52:22.762620", + "temperature": 15.166, + "wind_dir_min": 325.0, + "wind_dir_avg": 15.0, + "wind_dir_max": 22.0, + "wind_speed_min": 19.629, + "wind_speed_avg": 29.5, + "wind_speed_max": 32.823, + "relative_humidity": 30.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:51:18.938154", + "temperature": 15.166, + "wind_dir_min": 7.0, + "wind_dir_avg": 19.0, + "wind_dir_max": 22.0, + "wind_speed_min": 26.387, + "wind_speed_avg": 29.5, + "wind_speed_max": 32.019, + "relative_humidity": 30.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:50:15.117338", + "temperature": 15.166, + "wind_dir_min": 316.0, + "wind_dir_avg": 7.0, + "wind_dir_max": 24.0, + "wind_speed_min": 6.436, + "wind_speed_avg": 29.5, + "wind_speed_max": 32.18, + "relative_humidity": 30.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:49:11.296760", + "temperature": 15.277, + "wind_dir_min": 313.0, + "wind_dir_avg": 356.0, + "wind_dir_max": 24.0, + "wind_speed_min": 6.275, + "wind_speed_avg": 29.5, + "wind_speed_max": 32.18, + "relative_humidity": 30.6, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:48:07.829627", + "temperature": null, + "wind_dir_min": null, + "wind_dir_avg": null, + "wind_dir_max": null, + "wind_speed_min": null, + "wind_speed_avg": null, + "wind_speed_max": null, + "relative_humidity": null, + "air_pressure": null, + "rain_intensity": null + }, + { + "ts": "2024-11-27T02:47:04.517267", + "temperature": 15.277, + "wind_dir_min": 293.0, + "wind_dir_avg": 14.0, + "wind_dir_max": 44.0, + "wind_speed_min": 5.953, + "wind_speed_avg": 29.5, + "wind_speed_max": 31.375, + "relative_humidity": 30.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:46:00.656891", + "temperature": 15.277, + "wind_dir_min": 282.0, + "wind_dir_avg": 335.0, + "wind_dir_max": 19.0, + "wind_speed_min": 6.436, + "wind_speed_avg": 29.5, + "wind_speed_max": 30.571, + "relative_humidity": 30.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:44:56.835983", + "temperature": 15.277, + "wind_dir_min": 269.0, + "wind_dir_avg": 337.0, + "wind_dir_max": 22.0, + "wind_speed_min": 5.309, + "wind_speed_avg": 29.5, + "wind_speed_max": 28.962, + "relative_humidity": 30.5, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:43:53.018148", + "temperature": 15.388, + "wind_dir_min": 276.0, + "wind_dir_avg": 330.0, + "wind_dir_max": 22.0, + "wind_speed_min": 6.275, + "wind_speed_avg": 29.5, + "wind_speed_max": 28.801, + "relative_humidity": 30.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:42:49.195935", + "temperature": 15.388, + "wind_dir_min": 302.0, + "wind_dir_avg": 347.0, + "wind_dir_max": 40.0, + "wind_speed_min": 6.436, + "wind_speed_avg": 29.5, + "wind_speed_max": 30.249, + "relative_humidity": 30.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:41:45.375189", + "temperature": 15.388, + "wind_dir_min": 293.0, + "wind_dir_avg": 344.0, + "wind_dir_max": 21.0, + "wind_speed_min": 6.918, + "wind_speed_avg": 29.5, + "wind_speed_max": 30.41, + "relative_humidity": 30.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:40:41.556100", + "temperature": 15.388, + "wind_dir_min": 260.0, + "wind_dir_avg": 322.0, + "wind_dir_max": 21.0, + "wind_speed_min": 5.792, + "wind_speed_avg": 29.5, + "wind_speed_max": 30.249, + "relative_humidity": 30.3, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:39:37.735600", + "temperature": 15.388, + "wind_dir_min": 270.0, + "wind_dir_avg": 310.0, + "wind_dir_max": 32.0, + "wind_speed_min": 6.114, + "wind_speed_avg": 29.5, + "wind_speed_max": 9.975, + "relative_humidity": 30.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:38:33.916950", + "temperature": 15.388, + "wind_dir_min": 277.0, + "wind_dir_avg": 312.0, + "wind_dir_max": 0.0, + "wind_speed_min": 6.275, + "wind_speed_avg": 29.5, + "wind_speed_max": 13.515, + "relative_humidity": 30.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:37:30.094991", + "temperature": 15.5, + "wind_dir_min": 267.0, + "wind_dir_avg": 320.0, + "wind_dir_max": 359.0, + "wind_speed_min": 6.596, + "wind_speed_avg": 29.5, + "wind_speed_max": 11.906, + "relative_humidity": 30.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:36:26.276297", + "temperature": 15.5, + "wind_dir_min": 233.0, + "wind_dir_avg": 297.0, + "wind_dir_max": 351.0, + "wind_speed_min": 6.918, + "wind_speed_avg": 29.5, + "wind_speed_max": 15.124, + "relative_humidity": 30.2, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:35:22.453634", + "temperature": 15.5, + "wind_dir_min": 257.0, + "wind_dir_avg": 312.0, + "wind_dir_max": 13.0, + "wind_speed_min": 7.24, + "wind_speed_avg": 29.5, + "wind_speed_max": 20.917, + "relative_humidity": 29.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:34:18.594648", + "temperature": 15.5, + "wind_dir_min": 239.0, + "wind_dir_avg": 299.0, + "wind_dir_max": 350.0, + "wind_speed_min": 8.849, + "wind_speed_avg": 29.5, + "wind_speed_max": 18.503, + "relative_humidity": 30.1, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:33:14.780063", + "temperature": 15.5, + "wind_dir_min": 231.0, + "wind_dir_avg": 293.0, + "wind_dir_max": 14.0, + "wind_speed_min": 6.918, + "wind_speed_avg": 29.5, + "wind_speed_max": 14.641, + "relative_humidity": 30.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:32:10.953543", + "temperature": 15.5, + "wind_dir_min": 258.0, + "wind_dir_avg": 323.0, + "wind_dir_max": 0.0, + "wind_speed_min": 6.114, + "wind_speed_avg": 29.5, + "wind_speed_max": 19.308, + "relative_humidity": 29.9, + "air_pressure": 22.91, + "rain_intensity": 0.0 + }, + { + "ts": "2024-11-27T02:31:06.685259", + "temperature": null, + "wind_dir_min": null, + "wind_dir_avg": null, + "wind_dir_max": null, + "wind_speed_min": null, + "wind_speed_avg": null, + "wind_speed_max": null, + "relative_humidity": null, + "air_pressure": null, + "rain_intensity": null + }, + { + "ts": "2024-11-27T02:29:03.313219", + "temperature": 15.555, + "wind_dir_min": 297.0, + "wind_dir_avg": 329.0, + "wind_dir_max": 19.0, + "wind_speed_min": 6.918, + "wind_speed_avg": 29.5, + "wind_speed_max": 22.526, + "relative_humidity": 30.0, + "air_pressure": 22.91, + "rain_intensity": 0.0 + } +] diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..5305e0d --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-11-27 +# @Filename: test_weather.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import json +import pathlib +import time + +import polars +import pytest +import pytest_mock + +from lvmopstools.weather import get_weather_data, is_weather_data_safe + + +@pytest.fixture +def mock_get_from_lco_api(mocker: pytest_mock.MockerFixture): + data = pathlib.Path(__file__).parent / "data" / "weather_response.json" + mocker.patch( + "lvmopstools.weather.get_from_lco_api", + return_value=json.loads(data.read_text()), + ) + + +@pytest.mark.parametrize( + "start_time", + [1732678137.5346804, "2024-11-27T03:56:10.618329", "2024-11-27 03:56:10Z"], +) +async def test_get_weather_data(mock_get_from_lco_api, start_time: str | float): + data = await get_weather_data(start_time) + + assert isinstance(data, polars.DataFrame) + assert data.height == 94 + + +async def test_is_weather_data_safe( + mock_get_from_lco_api, + mocker: pytest_mock.MockerFixture, +): + mocker.patch.object(time, "time", return_value=1732680854.704) + + data = await get_weather_data("2024-11-27T03:56:10.618329") + + assert is_weather_data_safe(data, "wind_speed_avg", 35) + assert not is_weather_data_safe(data, "wind_speed_avg", 5) + + # Values are such that the max avg wind speed in the last 60 minutes is 12.11 + # and the maximum in the last 30 minutes is 10.18 mph. + assert is_weather_data_safe(data, "wind_speed_avg", 12.5, reopen_value=10) + assert not is_weather_data_safe(data, "wind_speed_avg", 12, reopen_value=10) diff --git a/uv.lock b/uv.lock index 8e55a85..041aed4 100644 --- a/uv.lock +++ b/uv.lock @@ -125,6 +125,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925 }, ] +[[package]] +name = "cachetools" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -377,6 +386,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, ] +[[package]] +name = "durationpy" +version = "0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/e9/f49c4e7fccb77fa5c43c2480e09a857a78b41e7331a75e128ed5df45c56b/durationpy-0.9.tar.gz", hash = "sha256:fd3feb0a69a0057d582ef643c355c40d2fa1c942191f914d12203b1a01ac722a", size = 3186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461 }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -419,6 +437,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/48/e791a7ed487dbb9729ef32bb5d1af16693d8925f4366befef54119b2e576/furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c", size = 341333 }, ] +[[package]] +name = "google-auth" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/71/4c5387d8a3e46e3526a8190ae396659484377a73b33030614dd3b28e7ded/google_auth-2.36.0.tar.gz", hash = "sha256:545e9618f2df0bcbb7dcbc45a546485b1212624716975a1ea5ae8149ce769ab1", size = 268336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3d5087d27865c2f0431b942b5c4500b7d1b744dd3262fdc973a4c39d099e/google_auth-2.36.0-py2.py3-none-any.whl", hash = "sha256:51a15d47028b66fd36e5c64a82d2d57480075bccc7da37cde257fc94177a61fb", size = 209519 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -475,6 +507,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, ] +[[package]] +name = "influxdb-client" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "python-dateutil" }, + { name = "reactivex" }, + { name = "setuptools" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/d7/07b6d9c02b975ba7961427af5a40c910871a97f543b4f5762112084cea48/influxdb_client-1.47.0.tar.gz", hash = "sha256:549f2c0ad458bbf79de1291ad5b07b823d80a3bcdbe77b4f0b436461aa008e2b", size = 385472 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/21/ae735781be55697cb0f419d753ca9dd1a66a375a1fe2d9916cf90d2ab6de/influxdb_client-1.47.0-py3-none-any.whl", hash = "sha256:5054cf0a9ac67e4e00dcb8bdef19b846c7972b6cd9b9e9fa12b1cff9cc586fc9", size = 745773 }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -589,9 +637,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, ] +[[package]] +name = "kubernetes" +version = "31.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "google-auth" }, + { name = "oauthlib" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/bd/ffcd3104155b467347cd9b3a64eb24182e459579845196b3a200569c8912/kubernetes-31.0.0.tar.gz", hash = "sha256:28945de906c8c259c1ebe62703b56a03b714049372196f854105afe4e6d014c0", size = 916096 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/a8/17f5e28cecdbd6d48127c22abdb794740803491f422a11905c4569d8e139/kubernetes-31.0.0-py2.py3-none-any.whl", hash = "sha256:bf141e2d380c8520eada8b351f4e319ffee9636328c137aa432bc486ca1200e1", size = 1857013 }, +] + [[package]] name = "lvmopstools" -version = "0.3.10a0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "asyncudp" }, @@ -607,6 +677,12 @@ dependencies = [ ds9 = [ { name = "pyds9" }, ] +influxdb = [ + { name = "influxdb-client" }, +] +kubernetes = [ + { name = "kubernetes" }, +] [package.dev-dependencies] dev = [ @@ -636,6 +712,8 @@ dev = [ requires-dist = [ { name = "asyncudp", specifier = ">=0.11.0" }, { name = "httpx", specifier = ">=0.27.2" }, + { name = "influxdb-client", marker = "extra == 'influxdb'", specifier = ">=1.47.0" }, + { name = "kubernetes", marker = "extra == 'kubernetes'", specifier = ">=31.0.0" }, { name = "polars", specifier = ">=1.13.0" }, { name = "pyds9", marker = "extra == 'ds9'", specifier = ">=1.8.1" }, { name = "sdss-clu", specifier = ">=2.2.7" }, @@ -947,6 +1025,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/c2/d1fee6ba999aa7cd41ca6856937f2baaf604c3eec1565eae63451ec31e5e/numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb", size = 12771397 }, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, +] + [[package]] name = "packaging" version = "24.2" @@ -1144,6 +1231,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 }, +] + [[package]] name = "pydantic" version = "2.10.2" @@ -1328,6 +1436,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-json-logger" version = "2.0.7" @@ -1381,6 +1501,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "reactivex" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/63/f776322df4d7b456446eff78c4e64f14c3c26d57d46b4e06c18807d5d99c/reactivex-4.0.4.tar.gz", hash = "sha256:e912e6591022ab9176df8348a653fe8c8fa7a301f26f9931c9d8c78a650e04e8", size = 119177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3f/2ed8c1b8fe3fc2ed816ba40554ef703aad8c51700e2606c139fcf9b7f791/reactivex-4.0.4-py3-none-any.whl", hash = "sha256:0004796c420bd9e68aad8e65627d85a8e13f293de76656165dffbcb3a0e3fb6a", size = 217791 }, +] + [[package]] name = "referencing" version = "0.35.1" @@ -1409,6 +1541,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + [[package]] name = "restructuredtext-lint" version = "1.4.0" @@ -1504,6 +1649,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/86/6f72984a284d720d84fba5ee7b0d1b0d320978b516497cbfd6e335e95a3e/rpds_py-0.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3e30a69a706e8ea20444b98a49f386c17b26f860aa9245329bab0851ed100677", size = 219621 }, ] +[[package]] +name = "rsa" +version = "4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 }, +] + [[package]] name = "rstcheck" version = "6.2.4" @@ -1608,6 +1765,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/c9/abeae708533b4005eb5c79e55c05ff2030fc79d279c55746ece9ec91764e/sdsstools-1.8.2-py3-none-any.whl", hash = "sha256:f1f3a0178309ceca3321ea4cc01baf39a7b7179559e0d9566b70f9761d103925", size = 55910 }, ] +[[package]] +name = "setuptools" +version = "75.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2027,6 +2193,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + [[package]] name = "websockets" version = "14.1"