tangram-weather 0.2.1__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.
- tangram_weather/WindFieldLayer.vue +177 -0
- tangram_weather/__init__.py +79 -0
- tangram_weather/arpege.py +133 -0
- tangram_weather/dist-frontend/__vite-optional-peer-dep_geotiff_weatherlayers-gl_false-Cjw5BclT.js +6 -0
- tangram_weather/dist-frontend/__vite-optional-peer-dep_geotiff_weatherlayers-gl_false-Cjw5BclT.js.map +1 -0
- tangram_weather/dist-frontend/index.css +1 -0
- tangram_weather/dist-frontend/index.js +11439 -0
- tangram_weather/dist-frontend/index.js.map +1 -0
- tangram_weather/dist-frontend/plugin.json +5 -0
- tangram_weather/index.ts +9 -0
- tangram_weather/package.json +17 -0
- tangram_weather-0.2.1.dist-info/METADATA +48 -0
- tangram_weather-0.2.1.dist-info/RECORD +15 -0
- tangram_weather-0.2.1.dist-info/WHEEL +4 -0
- tangram_weather-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -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 @@
|
|
|
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}
|