diff --git a/poetry.lock b/poetry.lock index f58e95c..9f06ebf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -934,6 +934,27 @@ files = [ {file = "hiredis-2.3.2.tar.gz", hash = "sha256:733e2456b68f3f126ddaf2cd500a33b25146c3676b97ea843665717bda0c5d43"}, ] +[[package]] +name = "httpcore" +version = "1.0.4" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"}, + {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.25.0)"] + [[package]] name = "httptools" version = "0.6.1" @@ -982,6 +1003,30 @@ files = [ [package.extras] test = ["Cython (>=0.29.24,<0.30.0)"] +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "idna" version = "3.6" @@ -2796,4 +2841,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11,<3.13" -content-hash = "2c99565061d87c9768092f2b4a56156300932c86d320c6156a282a688b3fefc0" +content-hash = "0555b82e90d32242c400ee63fc6684f4eb0cb91760d51df04054762d32cd435c" diff --git a/pyproject.toml b/pyproject.toml index e40c086..b091697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ astropy = "^6.0.0" astroplan = "^0.9.1" polars = "^0.20.16" redis = {version = "^5.0.3", extras = ["hiredis"]} +httpx = "^0.27.0" [tool.poetry.group.dev.dependencies] ipython = ">=8.0.0" diff --git a/src/lvmapi/app.py b/src/lvmapi/app.py index 621d115..413e3b6 100644 --- a/src/lvmapi/app.py +++ b/src/lvmapi/app.py @@ -11,7 +11,14 @@ from fastapi import FastAPI from lvmapi import auth -from lvmapi.routers import ephemeris, overwatcher, slack, spectrographs, telescopes +from lvmapi.routers import ( + ephemeris, + overwatcher, + slack, + spectrographs, + telescopes, + weather, +) app = FastAPI() @@ -21,6 +28,7 @@ app.include_router(slack.router) app.include_router(ephemeris.router) app.include_router(overwatcher.router) +app.include_router(weather.router) @app.get("/") diff --git a/src/lvmapi/routers/weather.py b/src/lvmapi/routers/weather.py new file mode 100644 index 0000000..661f775 --- /dev/null +++ b/src/lvmapi/routers/weather.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-03-26 +# @Filename: weather.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import polars +from fastapi import APIRouter + +from lvmapi.tools.weather import get_weather_data + + +router = APIRouter(prefix="/weather", tags=["weather"]) + + +@router.get("/") +@router.get("/summary") +async def get_weather(station: str = "DuPont") -> list[dict]: + """Returns the weather summary (last 60 minutes) from a weather station.""" + + df = await get_weather_data(station=station) + + return df.with_columns(ts=polars.col.ts.dt.to_string("%FT%X")).to_dicts() diff --git a/src/lvmapi/tools/weather.py b/src/lvmapi/tools/weather.py new file mode 100644 index 0000000..98d0cb3 --- /dev/null +++ b/src/lvmapi/tools/weather.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-03-26 +# @Filename: weather.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import httpx +import polars +from astropy.time import Time, TimeDelta + + +__all__ = ['get_weather_data'] + + +WEATHER_URL = "http://dataservice.lco.cl/vaisala/data" + + +async def get_weather_data( + end_time: str | None = None, + start_time: str | float = 3600, + station="DuPont", +): + """Returns weather data from the Vaisala weather station. + + Parameters + ---------- + end_time + The end time for the query. If not provided, the current time is used. + The time can be an Astropy Time object or a string in ISO format. + start_time + The start time for the query. The time can be an Astropy Time object, + a string in ISO format, or a float indicating with a time delta in seconds + with respect to ``end_time``. + + Returns + ------- + dataframe + A Polars dataframe with the time series weather data. + + """ + + if station not in ["DuPont", "C40", "Magellan"]: + raise ValueError("station must be one of 'DuPont', 'C40', or 'Magellan'.") + + if end_time is None: + end_time_ap = Time.now() + elif isinstance(end_time, str): + end_time_ap = Time(end_time) + elif isinstance(end_time, Time): + end_time_ap = end_time + else: + raise ValueError("end_time must be a string or an Astropy Time object.") + + if isinstance(start_time, str): + start_time_ap = Time(start_time) + elif isinstance(start_time, (int, float)): + start_time_ap = end_time_ap - TimeDelta(start_time, format="sec") + elif isinstance(start_time, Time): + start_time_ap = start_time + else: + raise ValueError( + "start_time must be a string, a time delta in seconds, " + "or an Astropy Time object." + ) + + end_time_ap.precision = 0 + start_time_ap.precision = 0 + + async with httpx.AsyncClient() as client: + response = await client.get( + WEATHER_URL, + params={ + "start_ts": str(start_time_ap.iso), + "end_ts": str(end_time_ap.iso), + "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.") + + results = data["results"] + + df = polars.DataFrame(results) + df = df.with_columns( + ts=polars.col("ts").str.to_datetime(time_unit="ms"), + station=polars.lit(station, polars.String), + ) + + # Delete rows with all null values. + df = df.filter(~polars.all_horizontal(polars.exclude("ts").is_null())) + + # Sort by timestamp + df = df.sort("ts") + + return df