@aeroswarm/weather 0.1.3 → 0.1.4
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.
- package/GRID_STANDARD.md +68 -0
- package/README.md +8 -0
- package/dist/gridSeries.d.ts +59 -0
- package/dist/gridSeries.d.ts.map +1 -0
- package/dist/gridSeries.test.d.ts +2 -0
- package/dist/gridSeries.test.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +220 -4
- package/dist/openMeteo.d.ts +40 -0
- package/dist/openMeteo.d.ts.map +1 -0
- package/package.json +3 -2
package/GRID_STANDARD.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# AeroSwarm Weather Grid (`aeroswarm.weather-grid`) v1
|
|
2
|
+
|
|
3
|
+
One JSON format for gridded weather forecasts, whatever the source (a model API, a converted GRIB
|
|
4
|
+
file, your own service). A source only has to produce this document; the renderer and the timeline
|
|
5
|
+
never need to know where the data came from.
|
|
6
|
+
|
|
7
|
+
- Read: `parseWeatherGridSeries(json)` (strict, throws a message naming the bad part).
|
|
8
|
+
- Write: `serializeWeatherGridSeries(series)`.
|
|
9
|
+
- Use: `seriesFieldsAt(series, time)` blends the two nearest frames; `null` outside the time range.
|
|
10
|
+
- Open-Meteo: `latticePoints(lattice)` gives the points to request, `weatherGridFromOpenMeteo(response,
|
|
11
|
+
lattice)` turns the answer into this format. Nothing here calls the network.
|
|
12
|
+
|
|
13
|
+
## Document
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"standard": "aeroswarm.weather-grid",
|
|
18
|
+
"version": 1,
|
|
19
|
+
"source": {
|
|
20
|
+
"name": "open-meteo",
|
|
21
|
+
"model": "icon_seamless",
|
|
22
|
+
"runAt": "2026-11-02T00:00:00Z",
|
|
23
|
+
"generatedAt": "2026-11-02T04:12:00Z"
|
|
24
|
+
},
|
|
25
|
+
"grid": { "west": 105, "south": -11, "east": 118, "north": -5, "nx": 14, "ny": 13 },
|
|
26
|
+
"times": ["2026-11-02T04:00:00Z", "2026-11-02T05:00:00Z"],
|
|
27
|
+
"fields": {
|
|
28
|
+
"windU": [[/* nx*ny values for time 0 */], [/* time 1 */]],
|
|
29
|
+
"windV": [[...], [...]],
|
|
30
|
+
"cloud": [[...], [...]],
|
|
31
|
+
"rain": [[...], [...]],
|
|
32
|
+
"temperature": [[...], [...]]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Rules
|
|
38
|
+
|
|
39
|
+
| Part | Rule |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `standard`, `version` | Exactly `"aeroswarm.weather-grid"` and `1`. |
|
|
42
|
+
| `source.name` | Required. `model`, `runAt`, `generatedAt` are optional (ISO 8601). |
|
|
43
|
+
| `grid` | `west < east`, `south < north`, `nx` and `ny` integers >= 2. Cells sit on the lattice points: column 0 is `west`, the last column is `east`; row 0 is `north`, the last row is `south`. |
|
|
44
|
+
| `times` | At least one ISO 8601 time in UTC, strictly increasing. |
|
|
45
|
+
| `fields` | At least one quantity. Each present quantity has exactly one array per time, and each array has exactly `nx * ny` values in row-major order (row 0 first). `null` means no data. |
|
|
46
|
+
| `windU` / `windV` | Both or neither. m/s. `windU` blows towards the east, `windV` towards the north. |
|
|
47
|
+
| `cloud` | Total cloud cover, percent 0-100. |
|
|
48
|
+
| `rain` | Precipitation rate, mm per hour. |
|
|
49
|
+
| `temperature` | Degrees Celsius. |
|
|
50
|
+
|
|
51
|
+
Wind is stored as components, not speed and direction, so frames can be blended and interpolated
|
|
52
|
+
without wrapping problems. To convert: `u = -speed * sin(from)`, `v = -speed * cos(from)`, where
|
|
53
|
+
`from` is the direction the wind comes FROM (meteorological, degrees from north).
|
|
54
|
+
|
|
55
|
+
## Versioning
|
|
56
|
+
|
|
57
|
+
A change that breaks readers (a different layout, a changed unit) raises `version`. Adding an optional
|
|
58
|
+
quantity or an optional `source` field does not. A reader must reject a `version` it does not know
|
|
59
|
+
instead of guessing.
|
|
60
|
+
|
|
61
|
+
## Producing it
|
|
62
|
+
|
|
63
|
+
- **Open-Meteo (in an app, not in this package):** request the lattice points with
|
|
64
|
+
`hourly=wind_speed_10m,wind_direction_10m,cloud_cover,precipitation,temperature_2m`,
|
|
65
|
+
`wind_speed_unit=ms`, `timezone=GMT`, then call `weatherGridFromOpenMeteo`.
|
|
66
|
+
- **GRIB:** convert to JSON on the server (`grib2json` or `wgrib2`), then build the document from the
|
|
67
|
+
two wind records and any scalar fields.
|
|
68
|
+
- **Your own service:** return this document directly; the app loads it from a URL or a file.
|
package/README.md
CHANGED
|
@@ -38,3 +38,11 @@ representation around the airport, not the real position of clouds or rain.
|
|
|
38
38
|
- `sampleWind`, `sampleScalar` (bilinear) and `blendWindGrids` (between two forecast hours).
|
|
39
39
|
|
|
40
40
|
Row 0 is north, wind is in m/s (`u` east, `v` north), cells without data are NaN.
|
|
41
|
+
|
|
42
|
+
## Gridded forecast standard
|
|
43
|
+
|
|
44
|
+
`GRID_STANDARD.md` defines `aeroswarm.weather-grid` v1: one JSON format (grid + times + wind/cloud/rain/
|
|
45
|
+
temperature) for any model source. `parseWeatherGridSeries`, `serializeWeatherGridSeries`,
|
|
46
|
+
`seriesFieldsAt`, and the Open-Meteo adapter (`latticePoints`, `weatherGridFromOpenMeteo`) are in the
|
|
47
|
+
package; the network call stays in the app.
|
|
48
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standar data medan cuaca AeroSwarm (`aeroswarm.weather-grid` versi 1).
|
|
3
|
+
*
|
|
4
|
+
* Satu format untuk semua sumber model (Open-Meteo, hasil konversi GRIB, layanan sendiri): grid
|
|
5
|
+
* beraturan, beberapa waktu, dan beberapa besaran. Sumber apa pun cukup menghasilkan format ini; renderer
|
|
6
|
+
* dan timeline tidak perlu tahu asalnya. Lihat GRID_STANDARD.md untuk bentuk JSON-nya.
|
|
7
|
+
*
|
|
8
|
+
* Konvensi: baris 0 = utara, kolom 0 = barat. Angin m/s (`windU` ke timur, `windV` ke utara), awan %,
|
|
9
|
+
* hujan mm/jam, suhu °C. Nilai `null` = tanpa data. Waktu UTC.
|
|
10
|
+
*/
|
|
11
|
+
import { type GridBounds, type ScalarGrid, type WindGrid } from "./grid.js";
|
|
12
|
+
export declare const WEATHER_GRID_STANDARD = "aeroswarm.weather-grid";
|
|
13
|
+
export declare const WEATHER_GRID_VERSION = 1;
|
|
14
|
+
export type GridQuantity = "windU" | "windV" | "cloud" | "rain" | "temperature";
|
|
15
|
+
export interface WeatherGridSource {
|
|
16
|
+
/** Nama sumber, mis. `open-meteo`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Nama model, mis. `icon_seamless`. */
|
|
19
|
+
model?: string;
|
|
20
|
+
/** Waktu model dijalankan (run), bila diketahui. */
|
|
21
|
+
runAt?: Date;
|
|
22
|
+
/** Waktu data diambil/dibuat. */
|
|
23
|
+
generatedAt?: Date;
|
|
24
|
+
}
|
|
25
|
+
export interface WeatherGridSeries {
|
|
26
|
+
source: WeatherGridSource;
|
|
27
|
+
bounds: GridBounds;
|
|
28
|
+
nx: number;
|
|
29
|
+
ny: number;
|
|
30
|
+
/** Waktu tiap bingkai, naik. */
|
|
31
|
+
times: Date[];
|
|
32
|
+
/** Per besaran: satu larik nx*ny per waktu. Besaran yang tidak ada dibiarkan kosong. */
|
|
33
|
+
fields: Partial<Record<GridQuantity, Float32Array[]>>;
|
|
34
|
+
}
|
|
35
|
+
export interface SeriesFields {
|
|
36
|
+
at: Date;
|
|
37
|
+
wind: WindGrid | null;
|
|
38
|
+
cloud: ScalarGrid | null;
|
|
39
|
+
rain: ScalarGrid | null;
|
|
40
|
+
temperature: ScalarGrid | null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Baca dan periksa JSON berstandar `aeroswarm.weather-grid` v1. Melempar galat yang menyebut bagian
|
|
44
|
+
* mana yang salah. Nilai di luar angka (null) menjadi NaN (tanpa data).
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseWeatherGridSeries(input: unknown): WeatherGridSeries;
|
|
47
|
+
/** Bentuk JSON berstandar dari seri (kebalikan `parseWeatherGridSeries`); NaN menjadi null. */
|
|
48
|
+
export declare function serializeWeatherGridSeries(series: WeatherGridSeries): Record<string, unknown>;
|
|
49
|
+
/** Rentang waktu seri. */
|
|
50
|
+
export declare function seriesRange(series: WeatherGridSeries): {
|
|
51
|
+
from: Date;
|
|
52
|
+
to: Date;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Medan pada waktu `at`, dicampur linear antar dua bingkai terdekat. Null di luar rentang waktu seri.
|
|
56
|
+
* Besaran yang tidak ada di seri dikembalikan null.
|
|
57
|
+
*/
|
|
58
|
+
export declare function seriesFieldsAt(series: WeatherGridSeries, at: Date): SeriesFields | null;
|
|
59
|
+
//# sourceMappingURL=gridSeries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gridSeries.d.ts","sourceRoot":"","sources":["../src/gridSeries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,QAAQ,EACd,MAAM,WAAW,CAAC;AAEnB,eAAO,MAAM,qBAAqB,2BAA2B,CAAC;AAC9D,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,aAAa,CAAC;AAEhF,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,KAAK,CAAC,EAAE,IAAI,CAAC;IACb,iCAAiC;IACjC,WAAW,CAAC,EAAE,IAAI,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,gCAAgC;IAChC,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,wFAAwF;IACxF,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,IAAI,CAAC;IACT,IAAI,EAAE,QAAQ,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,UAAU,GAAG,IAAI,CAAC;CAChC;AAaD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,CAgExE;AAED,+FAA+F;AAC/F,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAsB7F;AAED,0BAA0B;AAC1B,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,EAAE,EAAE,IAAI,CAAA;CAAE,CAK/E;AAqBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,iBAAiB,EAAE,EAAE,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,CAwBvF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gridSeries.test.d.ts","sourceRoot":"","sources":["../src/gridSeries.test.ts"],"names":[],"mappings":""}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export * from "./aftn.js";
|
|
|
3
3
|
export * from "./animation.js";
|
|
4
4
|
export * from "./conditions.js";
|
|
5
5
|
export * from "./grid.js";
|
|
6
|
+
export * from "./gridSeries.js";
|
|
7
|
+
export * from "./openMeteo.js";
|
|
6
8
|
export * from "./metar.js";
|
|
7
9
|
export * from "./taf.js";
|
|
8
10
|
export * from "./store.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAE3B;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,IAAiB,GAC3B,MAAM,EAAE,CAUV"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAE3B;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,IAAiB,GAC3B,MAAM,EAAE,CAUV"}
|
package/dist/index.js
CHANGED
|
@@ -688,10 +688,10 @@ function parseGrib2Json(input) {
|
|
|
688
688
|
v: orient(vRecord.data)
|
|
689
689
|
};
|
|
690
690
|
}
|
|
691
|
-
function stationWindsFromMetars(reports,
|
|
691
|
+
function stationWindsFromMetars(reports, locate2) {
|
|
692
692
|
const out = [];
|
|
693
693
|
for (const report of reports) {
|
|
694
|
-
const place =
|
|
694
|
+
const place = locate2(report.station);
|
|
695
695
|
if (!place) continue;
|
|
696
696
|
const { wind } = report.conditions;
|
|
697
697
|
out.push({
|
|
@@ -766,14 +766,222 @@ function decodeGridJson(input, fallback) {
|
|
|
766
766
|
throw new Error("Format medan tidak dikenal: harus grib2json, JSON grid {bounds,nx,ny,u,v|values}, atau respons banyak lokasi");
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
// src/gridSeries.ts
|
|
770
|
+
var WEATHER_GRID_STANDARD = "aeroswarm.weather-grid";
|
|
771
|
+
var WEATHER_GRID_VERSION = 1;
|
|
772
|
+
var QUANTITIES = ["windU", "windV", "cloud", "rain", "temperature"];
|
|
773
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
774
|
+
function parseDate(value, where) {
|
|
775
|
+
const date = typeof value === "string" ? new Date(value) : new Date(Number.NaN);
|
|
776
|
+
if (Number.isNaN(date.getTime())) throw new Error(`${where}: bukan tanggal ISO 8601 yang valid`);
|
|
777
|
+
return date;
|
|
778
|
+
}
|
|
779
|
+
function parseWeatherGridSeries(input) {
|
|
780
|
+
if (!isRecord2(input)) throw new Error("Bukan objek JSON");
|
|
781
|
+
if (input.standard !== WEATHER_GRID_STANDARD) {
|
|
782
|
+
throw new Error(`standard harus "${WEATHER_GRID_STANDARD}"`);
|
|
783
|
+
}
|
|
784
|
+
if (input.version !== WEATHER_GRID_VERSION) {
|
|
785
|
+
throw new Error(`version ${String(input.version)} tidak didukung (didukung: ${WEATHER_GRID_VERSION})`);
|
|
786
|
+
}
|
|
787
|
+
const src = isRecord2(input.source) ? input.source : null;
|
|
788
|
+
if (!src || typeof src.name !== "string" || src.name === "") throw new Error("source.name wajib diisi");
|
|
789
|
+
const source = {
|
|
790
|
+
name: src.name,
|
|
791
|
+
model: typeof src.model === "string" ? src.model : void 0,
|
|
792
|
+
runAt: src.runAt === void 0 ? void 0 : parseDate(src.runAt, "source.runAt"),
|
|
793
|
+
generatedAt: src.generatedAt === void 0 ? void 0 : parseDate(src.generatedAt, "source.generatedAt")
|
|
794
|
+
};
|
|
795
|
+
const grid = isRecord2(input.grid) ? input.grid : null;
|
|
796
|
+
if (!grid) throw new Error("grid wajib diisi");
|
|
797
|
+
const { west, south, east, north } = grid;
|
|
798
|
+
const nx = Number(grid.nx);
|
|
799
|
+
const ny = Number(grid.ny);
|
|
800
|
+
if (![west, south, east, north].every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
801
|
+
throw new Error("grid.west/south/east/north harus angka");
|
|
802
|
+
}
|
|
803
|
+
if (!Number.isInteger(nx) || !Number.isInteger(ny) || nx < 2 || ny < 2) {
|
|
804
|
+
throw new Error("grid.nx dan grid.ny harus bilangan bulat >= 2");
|
|
805
|
+
}
|
|
806
|
+
const bounds = { west, south, east, north };
|
|
807
|
+
if (bounds.west >= bounds.east || bounds.south >= bounds.north) {
|
|
808
|
+
throw new Error("grid: west < east dan south < north");
|
|
809
|
+
}
|
|
810
|
+
if (!Array.isArray(input.times) || input.times.length === 0) throw new Error("times wajib berisi minimal satu waktu");
|
|
811
|
+
const times = input.times.map((t, i) => parseDate(t, `times[${i}]`));
|
|
812
|
+
for (let i = 1; i < times.length; i++) {
|
|
813
|
+
if (times[i].getTime() <= times[i - 1].getTime()) {
|
|
814
|
+
throw new Error(`times[${i}] harus lebih baru dari times[${i - 1}]`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const rawFields = isRecord2(input.fields) ? input.fields : null;
|
|
818
|
+
if (!rawFields) throw new Error("fields wajib diisi");
|
|
819
|
+
const fields = {};
|
|
820
|
+
for (const quantity of QUANTITIES) {
|
|
821
|
+
const frames = rawFields[quantity];
|
|
822
|
+
if (frames === void 0) continue;
|
|
823
|
+
if (!Array.isArray(frames) || frames.length !== times.length) {
|
|
824
|
+
throw new Error(`fields.${quantity} harus berisi ${times.length} bingkai (satu per waktu)`);
|
|
825
|
+
}
|
|
826
|
+
fields[quantity] = frames.map((frame, t) => {
|
|
827
|
+
if (!Array.isArray(frame) || frame.length !== nx * ny) {
|
|
828
|
+
throw new Error(`fields.${quantity}[${t}] harus berisi ${nx * ny} nilai (nx * ny)`);
|
|
829
|
+
}
|
|
830
|
+
return Float32Array.from(frame, (v) => typeof v === "number" && Number.isFinite(v) ? v : Number.NaN);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
if (fields.windU === void 0 !== (fields.windV === void 0)) {
|
|
834
|
+
throw new Error("windU dan windV harus ada berdua");
|
|
835
|
+
}
|
|
836
|
+
if (Object.keys(fields).length === 0) throw new Error("fields tidak berisi satu besaran pun");
|
|
837
|
+
return { source, bounds, nx, ny, times, fields };
|
|
838
|
+
}
|
|
839
|
+
function serializeWeatherGridSeries(series) {
|
|
840
|
+
const fields = {};
|
|
841
|
+
for (const quantity of QUANTITIES) {
|
|
842
|
+
const frames = series.fields[quantity];
|
|
843
|
+
if (!frames) continue;
|
|
844
|
+
fields[quantity] = frames.map(
|
|
845
|
+
(frame) => Array.from(frame, (v) => Number.isNaN(v) ? null : Math.round(v * 1e3) / 1e3)
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
standard: WEATHER_GRID_STANDARD,
|
|
850
|
+
version: WEATHER_GRID_VERSION,
|
|
851
|
+
source: {
|
|
852
|
+
name: series.source.name,
|
|
853
|
+
...series.source.model ? { model: series.source.model } : {},
|
|
854
|
+
...series.source.runAt ? { runAt: series.source.runAt.toISOString() } : {},
|
|
855
|
+
...series.source.generatedAt ? { generatedAt: series.source.generatedAt.toISOString() } : {}
|
|
856
|
+
},
|
|
857
|
+
grid: { ...series.bounds, nx: series.nx, ny: series.ny },
|
|
858
|
+
times: series.times.map((t) => t.toISOString()),
|
|
859
|
+
fields
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function seriesRange(series) {
|
|
863
|
+
return {
|
|
864
|
+
from: series.times[0],
|
|
865
|
+
to: series.times[series.times.length - 1]
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function locate(times, at) {
|
|
869
|
+
const ms = at.getTime();
|
|
870
|
+
const first = times[0].getTime();
|
|
871
|
+
const last = times[times.length - 1].getTime();
|
|
872
|
+
if (ms < first || ms > last) return null;
|
|
873
|
+
for (let i = 1; i < times.length; i++) {
|
|
874
|
+
const hi = times[i].getTime();
|
|
875
|
+
if (ms <= hi) {
|
|
876
|
+
const lo = times[i - 1].getTime();
|
|
877
|
+
return { a: i - 1, b: i, t: (ms - lo) / (hi - lo || 1) };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return { a: times.length - 1, b: times.length - 1, t: 0 };
|
|
881
|
+
}
|
|
882
|
+
var mixFrames = (frames, a, b, t) => Float32Array.from(frames[a], (value, i) => value + (frames[b][i] - value) * t);
|
|
883
|
+
function seriesFieldsAt(series, at) {
|
|
884
|
+
const where = locate(series.times, at);
|
|
885
|
+
if (!where) return null;
|
|
886
|
+
const { bounds, nx, ny } = series;
|
|
887
|
+
const scalar = (quantity) => {
|
|
888
|
+
const frames = series.fields[quantity];
|
|
889
|
+
return frames ? { bounds, nx, ny, values: mixFrames(frames, where.a, where.b, where.t) } : null;
|
|
890
|
+
};
|
|
891
|
+
const u = series.fields.windU;
|
|
892
|
+
const v = series.fields.windV;
|
|
893
|
+
let wind = null;
|
|
894
|
+
if (u && v) {
|
|
895
|
+
const from = { bounds, nx, ny, u: u[where.a], v: v[where.a] };
|
|
896
|
+
const to = { bounds, nx, ny, u: u[where.b], v: v[where.b] };
|
|
897
|
+
wind = where.a === where.b ? from : blendWindGrids(from, to, where.t);
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
at,
|
|
901
|
+
wind,
|
|
902
|
+
cloud: scalar("cloud"),
|
|
903
|
+
rain: scalar("rain"),
|
|
904
|
+
temperature: scalar("temperature")
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/openMeteo.ts
|
|
909
|
+
function latticePoints(lattice) {
|
|
910
|
+
const latitude = [];
|
|
911
|
+
const longitude = [];
|
|
912
|
+
for (let row = 0; row < lattice.ny; row++) {
|
|
913
|
+
const lat = lattice.north - (lattice.north - lattice.south) * row / (lattice.ny - 1);
|
|
914
|
+
for (let col = 0; col < lattice.nx; col++) {
|
|
915
|
+
latitude.push(round(lat));
|
|
916
|
+
longitude.push(round(lattice.west + (lattice.east - lattice.west) * col / (lattice.nx - 1)));
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { latitude, longitude };
|
|
920
|
+
}
|
|
921
|
+
var round = (value) => Math.round(value * 1e4) / 1e4;
|
|
922
|
+
var SPEED_TO_MS = { "m/s": 1, "km/h": 1 / 3.6, kn: 0.514444, mph: 0.44704 };
|
|
923
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
924
|
+
var toUtc = (iso) => new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(iso) ? iso : `${iso}Z`);
|
|
925
|
+
function weatherGridFromOpenMeteo(response, lattice, options = {}) {
|
|
926
|
+
const items = Array.isArray(response) ? response : [response];
|
|
927
|
+
const expected = lattice.nx * lattice.ny;
|
|
928
|
+
if (items.length !== expected) {
|
|
929
|
+
throw new Error(`Respons berisi ${items.length} titik, kisi memerlukan ${expected} (${lattice.nx} x ${lattice.ny})`);
|
|
930
|
+
}
|
|
931
|
+
const hourly = items.map((item, i) => {
|
|
932
|
+
const h = isRecord3(item) && isRecord3(item.hourly) ? item.hourly : null;
|
|
933
|
+
if (!h || !Array.isArray(h.time)) throw new Error(`Titik ${i}: tidak ada data hourly.time`);
|
|
934
|
+
return h;
|
|
935
|
+
});
|
|
936
|
+
const timesIso = hourly[0].time;
|
|
937
|
+
if (timesIso.length === 0) throw new Error("hourly.time kosong");
|
|
938
|
+
for (const [i, h] of hourly.entries()) {
|
|
939
|
+
if (h.time.length !== timesIso.length) throw new Error(`Titik ${i}: jumlah waktu berbeda dari titik pertama`);
|
|
940
|
+
}
|
|
941
|
+
const times = timesIso.map(toUtc);
|
|
942
|
+
const toMs = SPEED_TO_MS[options.speedUnit ?? "m/s"] ?? 1;
|
|
943
|
+
const frame = (pick) => times.map((_, t) => Float32Array.from(hourly, (h) => pick(h, t)));
|
|
944
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : Number.NaN;
|
|
945
|
+
const fields = {};
|
|
946
|
+
const has = (key) => hourly.every((h) => Array.isArray(h[key]));
|
|
947
|
+
if (has("wind_speed_10m") && has("wind_direction_10m")) {
|
|
948
|
+
const uv = (h, t) => {
|
|
949
|
+
const speed = num(h.wind_speed_10m?.[t]) * toMs;
|
|
950
|
+
const dir = num(h.wind_direction_10m?.[t]);
|
|
951
|
+
return Number.isNaN(speed) || Number.isNaN(dir) ? { u: Number.NaN, v: Number.NaN } : uvFromSpeedDirection(speed, dir);
|
|
952
|
+
};
|
|
953
|
+
fields.windU = frame((h, t) => uv(h, t).u);
|
|
954
|
+
fields.windV = frame((h, t) => uv(h, t).v);
|
|
955
|
+
}
|
|
956
|
+
if (has("cloud_cover")) fields.cloud = frame((h, t) => num(h.cloud_cover?.[t]));
|
|
957
|
+
if (has("precipitation")) fields.rain = frame((h, t) => num(h.precipitation?.[t]));
|
|
958
|
+
if (has("temperature_2m")) fields.temperature = frame((h, t) => num(h.temperature_2m?.[t]));
|
|
959
|
+
if (Object.keys(fields).length === 0) {
|
|
960
|
+
throw new Error("Respons tidak memuat wind_speed_10m, wind_direction_10m, cloud_cover, precipitation, atau temperature_2m");
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
source: {
|
|
964
|
+
name: "open-meteo",
|
|
965
|
+
model: options.model,
|
|
966
|
+
runAt: options.runAt,
|
|
967
|
+
generatedAt: options.generatedAt ?? /* @__PURE__ */ new Date()
|
|
968
|
+
},
|
|
969
|
+
bounds: { west: lattice.west, south: lattice.south, east: lattice.east, north: lattice.north },
|
|
970
|
+
nx: lattice.nx,
|
|
971
|
+
ny: lattice.ny,
|
|
972
|
+
times,
|
|
973
|
+
fields
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
769
977
|
// src/store.ts
|
|
770
978
|
var MAX_OBSERVATIONS_PER_STATION = 48;
|
|
771
979
|
var MAX_FORECASTS_PER_STATION = 8;
|
|
772
980
|
var rainRate = (intensity) => intensity * intensity * 15;
|
|
773
981
|
var hhmm = (d) => `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
|
774
982
|
var WeatherStore = class {
|
|
775
|
-
constructor(
|
|
776
|
-
this.locate =
|
|
983
|
+
constructor(locate2) {
|
|
984
|
+
this.locate = locate2;
|
|
777
985
|
}
|
|
778
986
|
locate;
|
|
779
987
|
observations = /* @__PURE__ */ new Map();
|
|
@@ -990,6 +1198,8 @@ function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
|
|
|
990
1198
|
return reports;
|
|
991
1199
|
}
|
|
992
1200
|
export {
|
|
1201
|
+
WEATHER_GRID_STANDARD,
|
|
1202
|
+
WEATHER_GRID_VERSION,
|
|
993
1203
|
WeatherStore,
|
|
994
1204
|
animationFromConditions,
|
|
995
1205
|
animationSnapshot,
|
|
@@ -1002,19 +1212,25 @@ export {
|
|
|
1002
1212
|
extractReports,
|
|
1003
1213
|
forecastAt,
|
|
1004
1214
|
interpolateScalar,
|
|
1215
|
+
latticePoints,
|
|
1005
1216
|
mergeConditions,
|
|
1006
1217
|
parseConditionTokens,
|
|
1007
1218
|
parseGrib2Json,
|
|
1008
1219
|
parseGridJson,
|
|
1009
1220
|
parseMetar,
|
|
1010
1221
|
parseTaf,
|
|
1222
|
+
parseWeatherGridSeries,
|
|
1011
1223
|
pointOf,
|
|
1012
1224
|
resolveDayTime,
|
|
1013
1225
|
sampleScalar,
|
|
1014
1226
|
sampleWind,
|
|
1227
|
+
serializeWeatherGridSeries,
|
|
1228
|
+
seriesFieldsAt,
|
|
1229
|
+
seriesRange,
|
|
1015
1230
|
speedOf,
|
|
1016
1231
|
stationWindsFromMetars,
|
|
1017
1232
|
stationWindsFromModelPoints,
|
|
1018
1233
|
uvFromSpeedDirection,
|
|
1234
|
+
weatherGridFromOpenMeteo,
|
|
1019
1235
|
windGridFromStations
|
|
1020
1236
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type WeatherGridSeries } from "./gridSeries.js";
|
|
2
|
+
/**
|
|
3
|
+
* Adaptor respons layanan model bergaya Open-Meteo menjadi standar `aeroswarm.weather-grid`. Murni:
|
|
4
|
+
* tidak memanggil jaringan. Pemanggilnya (aplikasi) yang mengambil datanya.
|
|
5
|
+
*
|
|
6
|
+
* Titik diminta sebagai kisi beraturan (lihat `latticePoints`), jadi responsnya langsung menjadi
|
|
7
|
+
* grid tanpa interpolasi.
|
|
8
|
+
*/
|
|
9
|
+
export interface Lattice {
|
|
10
|
+
west: number;
|
|
11
|
+
south: number;
|
|
12
|
+
east: number;
|
|
13
|
+
north: number;
|
|
14
|
+
nx: number;
|
|
15
|
+
ny: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Titik-titik kisi, baris demi baris dari utara ke selatan, kolom dari barat ke timur. Urutan ini
|
|
19
|
+
* sama dengan urutan respons banyak lokasi, dan dengan urutan sel pada grid standar.
|
|
20
|
+
*/
|
|
21
|
+
export declare function latticePoints(lattice: Lattice): {
|
|
22
|
+
latitude: number[];
|
|
23
|
+
longitude: number[];
|
|
24
|
+
};
|
|
25
|
+
declare const SPEED_TO_MS: Record<string, number>;
|
|
26
|
+
export interface OpenMeteoOptions {
|
|
27
|
+
/** Satuan kecepatan angin di respons. Bawaan `m/s` (minta dengan `wind_speed_unit=ms`). */
|
|
28
|
+
speedUnit?: keyof typeof SPEED_TO_MS;
|
|
29
|
+
model?: string;
|
|
30
|
+
runAt?: Date;
|
|
31
|
+
generatedAt?: Date;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Susun seri standar dari respons banyak lokasi (satu elemen per titik kisi, urutan `latticePoints`).
|
|
35
|
+
* Meminta `hourly=wind_speed_10m,wind_direction_10m,cloud_cover,precipitation,temperature_2m`,
|
|
36
|
+
* `timezone=GMT`, dan `wind_speed_unit=ms`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function weatherGridFromOpenMeteo(response: unknown, lattice: Lattice, options?: OpenMeteoOptions): WeatherGridSeries;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=openMeteo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openMeteo.d.ts","sourceRoot":"","sources":["../src/openMeteo.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AAEH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAW3F;AAID,QAAA,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAA6D,CAAC;AAEtG,MAAM,WAAW,gBAAgB;IAC/B,2FAA2F;IAC3F,SAAS,CAAC,EAAE,MAAM,OAAO,WAAW,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,IAAI,CAAC;IACb,WAAW,CAAC,EAAE,IAAI,CAAC;CACpB;AAiBD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,OAAO,EACjB,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,gBAAqB,GAC7B,iBAAiB,CAyDnB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeroswarm/weather",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Decode raw AFTN/METAR/SPECI/TAF messages and turn them into animation parameters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"dist"
|
|
16
|
+
"dist",
|
|
17
|
+
"GRID_STANDARD.md"
|
|
17
18
|
],
|
|
18
19
|
"publishConfig": {
|
|
19
20
|
"access": "public",
|