tangram-weather 0.2.0__py2.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,177 @@
1
+ <template>
2
+ <div class="wind-altitude-control" @mousedown.stop @touchstart.stop>
3
+ <label for="hpa-slider">{{ isobaric }}hPa | FL{{ FL }}</label>
4
+ <input
5
+ id="hpa-slider"
6
+ v-model="isobaric"
7
+ type="range"
8
+ min="100"
9
+ max="1000"
10
+ step="50"
11
+ @input="updateLabel"
12
+ @change="updateValue"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { ref, watch, inject, onUnmounted, onMounted } from "vue";
19
+ import type { TangramApi, Disposable } from "@open-aviation/tangram-core/api";
20
+ import { ParticleLayer, ImageType } from "weatherlayers-gl";
21
+
22
+ const tangramApi = inject<TangramApi>("tangramApi");
23
+ if (!tangramApi) {
24
+ throw new Error("assert: tangram api not provided");
25
+ }
26
+
27
+ const isobaric = ref(300);
28
+ const FL = ref(300);
29
+ const layerDisposable = ref<Disposable | null>(null);
30
+
31
+ const convertHpaToFlightLevel = (hpa: number) => {
32
+ const P0 = 1013.25;
33
+ const T0 = 288.15;
34
+ const L = 0.0065;
35
+ const g = 9.80665;
36
+ const R = 287.05;
37
+ const H_TROP = 11000;
38
+
39
+ let altitude;
40
+ if (hpa > 226.32) {
41
+ altitude = (T0 / L) * (1 - Math.pow(hpa / P0, (L * R) / g));
42
+ } else {
43
+ const T_TROP = T0 - L * H_TROP;
44
+ const P_TROP = P0 * Math.pow(T_TROP / T0, g / (L * R));
45
+ altitude = H_TROP + ((T_TROP * R) / g) * Math.log(P_TROP / hpa);
46
+ }
47
+ return Math.round((altitude * 3.28084) / 1000) * 10;
48
+ };
49
+
50
+ const updateLabel = () => {
51
+ FL.value = convertHpaToFlightLevel(isobaric.value);
52
+ };
53
+
54
+ const updateValue = () => {
55
+ fetchAndDisplay();
56
+ };
57
+
58
+ async function loadTextureDataFromUri(
59
+ uri: string
60
+ ): Promise<{ data: Uint8ClampedArray; width: number; height: number }> {
61
+ return new Promise((resolve, reject) => {
62
+ const img = new Image();
63
+ img.onload = () => {
64
+ const canvas = document.createElement("canvas");
65
+ canvas.width = img.width;
66
+ canvas.height = img.height;
67
+ const ctx = canvas.getContext("2d");
68
+ if (!ctx) {
69
+ return reject(new Error("Could not get 2d context from canvas"));
70
+ }
71
+ ctx.drawImage(img, 0, 0);
72
+ const imageData = ctx.getImageData(0, 0, img.width, img.height);
73
+ resolve({
74
+ data: imageData.data,
75
+ width: imageData.width,
76
+ height: imageData.height
77
+ });
78
+ };
79
+ img.onerror = err => {
80
+ reject(err);
81
+ };
82
+ img.src = uri;
83
+ });
84
+ }
85
+
86
+ const fetchAndDisplay = async () => {
87
+ if (!tangramApi.map.isReady.value) return;
88
+
89
+ if (layerDisposable.value) {
90
+ layerDisposable.value.dispose();
91
+ layerDisposable.value = null;
92
+ }
93
+
94
+ try {
95
+ const response = await fetch(`/weather/wind?isobaric=${isobaric.value}`);
96
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
97
+ const { imageDataUri, bounds, imageUnscale } = await response.json();
98
+
99
+ const textureData = await loadTextureDataFromUri(imageDataUri);
100
+
101
+ const windPalette: [number, [number, number, number]][] = [
102
+ [0, [37, 99, 235]],
103
+ [10, [65, 171, 93]],
104
+ [20, [253, 174, 97]],
105
+ [30, [244, 109, 67]],
106
+ [40, [215, 25, 28]],
107
+ [50, [128, 0, 38]]
108
+ ];
109
+
110
+ const windLayer = new ParticleLayer({
111
+ id: "wind-field-layer",
112
+ image: textureData,
113
+ imageType: ImageType.VECTOR,
114
+ imageUnscale: imageUnscale,
115
+ bounds: bounds,
116
+
117
+ numParticles: 1500,
118
+ maxAge: 15,
119
+ speedFactor: 20,
120
+ width: 1,
121
+ palette: windPalette,
122
+ animate: true
123
+ });
124
+
125
+ layerDisposable.value = tangramApi.map.addLayer(windLayer);
126
+ } catch (error) {
127
+ console.error("Failed to fetch or display wind data:", error);
128
+ }
129
+ };
130
+
131
+ watch(
132
+ tangramApi.map.isReady,
133
+ isReady => {
134
+ if (isReady) {
135
+ fetchAndDisplay();
136
+ }
137
+ },
138
+ { immediate: true }
139
+ );
140
+
141
+ onMounted(() => {
142
+ FL.value = convertHpaToFlightLevel(isobaric.value);
143
+ });
144
+
145
+ onUnmounted(() => {
146
+ if (layerDisposable.value) {
147
+ layerDisposable.value.dispose();
148
+ layerDisposable.value = null;
149
+ }
150
+ });
151
+ </script>
152
+
153
+ <style scoped>
154
+ .wind-altitude-control {
155
+ position: absolute;
156
+ bottom: 20px;
157
+ right: 70px;
158
+ background: rgba(255, 255, 255, 0.8);
159
+ padding: 10px;
160
+ border-radius: 5px;
161
+ z-index: 1000;
162
+ }
163
+
164
+ .wind-altitude-control label {
165
+ font-family: "B612", sans-serif;
166
+ font-size: 12px;
167
+ }
168
+
169
+ input[type="range"] {
170
+ cursor: pointer;
171
+ width: 100%;
172
+ margin-top: 5px;
173
+ background: #bab0ac;
174
+ height: 2px;
175
+ border-radius: 5px;
176
+ }
177
+ </style>
@@ -0,0 +1,79 @@
1
+ import base64
2
+ import io
3
+ import logging
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ import tangram_core
8
+ from fastapi import APIRouter
9
+ from fastapi.responses import ORJSONResponse
10
+ from PIL import Image
11
+
12
+ from .arpege import latest_data as latest_arpege_data
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ router = APIRouter(
17
+ prefix="/weather",
18
+ tags=["weather"],
19
+ responses={404: {"description": "Not found"}},
20
+ )
21
+
22
+
23
+ @router.get("/")
24
+ async def get_weather() -> dict[str, str]:
25
+ return {"message": "This is the weather plugin response"}
26
+
27
+
28
+ @router.get("/wind")
29
+ async def wind(
30
+ isobaric: int, backend_state: tangram_core.InjectBackendState
31
+ ) -> ORJSONResponse:
32
+ logger.info("fetching wind data for %s", isobaric)
33
+
34
+ now = pd.Timestamp.now(tz="UTC").floor("1h")
35
+ ds = await latest_arpege_data(backend_state.http_client, now)
36
+ res = ds.sel(isobaricInhPa=isobaric, time=now.tz_convert(None))[["u", "v"]]
37
+
38
+ u_attrs = res.data_vars["u"].attrs
39
+
40
+ bounds = [
41
+ u_attrs["GRIB_longitudeOfFirstGridPointInDegrees"],
42
+ u_attrs["GRIB_latitudeOfLastGridPointInDegrees"],
43
+ u_attrs["GRIB_longitudeOfLastGridPointInDegrees"],
44
+ u_attrs["GRIB_latitudeOfFirstGridPointInDegrees"],
45
+ ]
46
+
47
+ u_data = res["u"].values
48
+ v_data = res["v"].values
49
+
50
+ valid_data_mask = ~np.isnan(u_data)
51
+
52
+ min_val, max_val = -70.0, 70.0
53
+ image_unscale = [min_val, max_val]
54
+ value_range = max_val - min_val
55
+
56
+ u_scaled = (np.nan_to_num(u_data, nan=0.0) - min_val) / value_range * 255
57
+ v_scaled = (np.nan_to_num(v_data, nan=0.0) - min_val) / value_range * 255
58
+
59
+ rgba_data = np.zeros((*u_data.shape, 4), dtype=np.uint8)
60
+ rgba_data[..., 0] = u_scaled.astype(np.uint8)
61
+ rgba_data[..., 1] = v_scaled.astype(np.uint8)
62
+ rgba_data[..., 3] = np.where(valid_data_mask, 255, 0)
63
+
64
+ image = Image.fromarray(rgba_data, "RGBA")
65
+ buffer = io.BytesIO()
66
+ image.save(buffer, format="PNG")
67
+ img_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
68
+ image_data_uri = f"data:image/png;base64,{img_str}"
69
+
70
+ response_content = {
71
+ "imageDataUri": image_data_uri,
72
+ "bounds": bounds,
73
+ "imageUnscale": image_unscale,
74
+ }
75
+
76
+ return ORJSONResponse(content=response_content)
77
+
78
+
79
+ plugin = tangram_core.Plugin(frontend_path="dist-frontend", routers=[router])
@@ -0,0 +1,133 @@
1
+ import asyncio
2
+ import logging
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import httpx
8
+ import pandas as pd
9
+ import xarray as xr
10
+ from tqdm.auto import tqdm
11
+
12
+ bare_url = "https://object.data.gouv.fr/meteofrance-pnt/pnt/"
13
+
14
+ # fmt:off
15
+ DEFAULT_LEVELS_37 = [
16
+ 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450,
17
+ 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
18
+ ]
19
+ DEFAULT_IP1_FEATURES = ['u', 'v', 't', 'r']
20
+ # fmt:on
21
+
22
+ tempdir = Path(tempfile.gettempdir())
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ async def download_with_progress(
27
+ client: httpx.AsyncClient, url: str, file: Path
28
+ ) -> None:
29
+ try:
30
+ async with client.stream("GET", url) as r:
31
+ if r.status_code != 200:
32
+ raise httpx.HTTPStatusError(
33
+ f"Error downloading data from {url}", request=r.request, response=r
34
+ )
35
+
36
+ total_size = int(r.headers.get("Content-Length", 0))
37
+ with file.open("wb") as buffer:
38
+ with tqdm(
39
+ total=total_size,
40
+ unit="B",
41
+ unit_scale=True,
42
+ desc=url.split("/")[-1],
43
+ ) as progress_bar:
44
+ first_chunk = True
45
+ async for chunk in r.aiter_bytes():
46
+ if first_chunk and chunk.startswith(b"<?xml"):
47
+ raise RuntimeError(
48
+ f"Error downloading data from {url}. "
49
+ "Check if the requested data is available."
50
+ )
51
+ first_chunk = False
52
+ await asyncio.to_thread(buffer.write, chunk)
53
+ progress_bar.update(len(chunk))
54
+ except (httpx.RequestError, RuntimeError) as e:
55
+ if file.exists():
56
+ file.unlink()
57
+ raise e
58
+
59
+
60
+ async def latest_data(
61
+ client: httpx.AsyncClient,
62
+ hour: pd.Timestamp,
63
+ model: str = "ARPEGE",
64
+ resolution: Literal["025", "01"] = "025",
65
+ package: Literal["SP1", "SP2", "IP1", "IP2", "IP3", "IP4", "HP1"] = "IP1",
66
+ time_range: Literal[
67
+ "000H024H", # on the 0.25 degree grid
68
+ "025H048H", # on the 0.25 degree grid
69
+ "049H072H", # on the 0.25 degree grid
70
+ "073H102H", # on the 0.25 degree grid
71
+ "000H012H", # on the 0.1 degree grid
72
+ "013H024H", # on the 0.1 degree grid
73
+ "025H036H", # on the 0.1 degree grid
74
+ "037H048H", # on the 0.1 degree grid
75
+ "049H060H", # on the 0.1 degree grid
76
+ "061H072H", # on the 0.1 degree grid
77
+ "073H084H", # on the 0.1 degree grid
78
+ "085H096H", # on the 0.1 degree grid
79
+ "097H102H", # on the 0.1 degree grid
80
+ ] = "000H024H",
81
+ recursion: int = 0,
82
+ ) -> xr.Dataset:
83
+ """
84
+ Fetch the latest ARPEGE data for a given hour.
85
+ """
86
+ # let's give them time to upload data to the repo
87
+ runtime = (hour - pd.Timedelta("2h")).floor("6h")
88
+
89
+ url = f"{bare_url}{runtime.isoformat()}/"
90
+ url += f"{model.lower()}/{resolution}/{package}/"
91
+ filename = f"{model.lower()}__{resolution}__{package}__"
92
+ filename += f"{time_range}__{runtime.isoformat()}.grib2"
93
+ filename = filename.replace("+00:00", "Z")
94
+ url += filename
95
+ url = url.replace("+00:00", "Z")
96
+
97
+ if not (tempdir / filename).exists():
98
+ # If the file does not exist, we try to download it.
99
+ try:
100
+ await download_with_progress(client, url, tempdir / filename)
101
+ except Exception:
102
+ (tempdir / filename).unlink(missing_ok=True) # remove the file if it exists
103
+ # If the download fails, we try to fetch the latest data
104
+ # (or survive with older data we may have in the /tmp directory)
105
+ if recursion >= 3:
106
+ raise # do not insist too much in history
107
+ return await latest_data(
108
+ client,
109
+ hour - pd.Timedelta("6h"),
110
+ model,
111
+ resolution,
112
+ package,
113
+ time_range,
114
+ recursion + 1,
115
+ )
116
+
117
+ def _load_and_process_dataset() -> xr.Dataset:
118
+ log.info(f"Loading dataset from {tempdir / filename}")
119
+ ds = xr.open_dataset(
120
+ tempdir / filename,
121
+ engine="cfgrib",
122
+ backend_kwargs={
123
+ "filter_by_keys": {
124
+ "typeOfLevel": "isobaricInhPa",
125
+ "level": DEFAULT_LEVELS_37,
126
+ }
127
+ },
128
+ )
129
+ ds = ds.assign(step=ds.time + ds.step).drop_vars("time")
130
+ ds = ds.rename(step="time")
131
+ return ds # type: ignore
132
+
133
+ return await asyncio.to_thread(_load_and_process_dataset)
@@ -0,0 +1,6 @@
1
+ const e = {};
2
+ throw new Error('Could not resolve "geotiff" imported by "weatherlayers-gl".');
3
+ export {
4
+ e as default
5
+ };
6
+ //# sourceMappingURL=__vite-optional-peer-dep_geotiff_weatherlayers-gl_false-Cjw5BclT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__vite-optional-peer-dep_geotiff_weatherlayers-gl_false-Cjw5BclT.js","sources":["../__vite-optional-peer-dep:geotiff:weatherlayers-gl:false"],"sourcesContent":["export default {};throw new Error(`Could not resolve \"geotiff\" imported by \"weatherlayers-gl\".`)"],"names":["__viteOptionalPeerDep_geotiff_weatherlayersGl_false"],"mappings":"AAAA,MAAAA,IAAe,CAAA;AAAG,MAAM,IAAI,MAAM,6DAA6D;"}
@@ -0,0 +1 @@
1
+ .wind-altitude-control[data-v-7a31b6fa]{position:absolute;bottom:20px;right:70px;background:#fffc;padding:10px;border-radius:5px;z-index:1000}.wind-altitude-control label[data-v-7a31b6fa]{font-family:B612,sans-serif;font-size:12px}input[type=range][data-v-7a31b6fa]{cursor:pointer;width:100%;margin-top:5px;background:#bab0ac;height:2px;border-radius:5px}