@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.
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gridSeries.test.d.ts.map
@@ -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";
@@ -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, locate) {
691
+ function stationWindsFromMetars(reports, locate2) {
692
692
  const out = [];
693
693
  for (const report of reports) {
694
- const place = locate(report.station);
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(locate) {
776
- this.locate = 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",
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",