@aeroswarm/weather 0.1.2 → 0.1.3

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/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./conditions.js";
5
5
  export * from "./grid.js";
6
6
  export * from "./metar.js";
7
7
  export * from "./taf.js";
8
+ export * from "./store.js";
8
9
  export * from "./time.js";
9
10
  export * from "./types.js";
10
11
  /**
@@ -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,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,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
@@ -766,6 +766,220 @@ 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/store.ts
770
+ var MAX_OBSERVATIONS_PER_STATION = 48;
771
+ var MAX_FORECASTS_PER_STATION = 8;
772
+ var rainRate = (intensity) => intensity * intensity * 15;
773
+ var hhmm = (d) => `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
774
+ var WeatherStore = class {
775
+ constructor(locate) {
776
+ this.locate = locate;
777
+ }
778
+ locate;
779
+ observations = /* @__PURE__ */ new Map();
780
+ forecasts = /* @__PURE__ */ new Map();
781
+ listeners = /* @__PURE__ */ new Set();
782
+ /** Naik tiap data berubah; berguna untuk memicu render ulang. */
783
+ version = 0;
784
+ /** Terima pesan mentah (laporan polos, buletin, atau pesan AFTN; boleh banyak laporan). */
785
+ ingest(message, options = {}) {
786
+ const receivedAt = options.receivedAt ?? /* @__PURE__ */ new Date();
787
+ const manual = options.manual === true;
788
+ const result = { metars: 0, tafs: 0, skipped: 0, stations: [] };
789
+ const touched = /* @__PURE__ */ new Set();
790
+ for (const raw of extractReports(message)) {
791
+ if (raw.kind === "TAF") {
792
+ const taf = parseTaf(raw.text, receivedAt);
793
+ if (!taf) {
794
+ result.skipped++;
795
+ continue;
796
+ }
797
+ this.add(this.forecasts, taf.station, { report: taf, manual, receivedAt }, MAX_FORECASTS_PER_STATION);
798
+ touched.add(taf.station);
799
+ result.tafs++;
800
+ } else {
801
+ const metar = parseMetar(raw.text, receivedAt);
802
+ if (!metar) {
803
+ result.skipped++;
804
+ continue;
805
+ }
806
+ this.add(this.observations, metar.station, { report: metar, manual, receivedAt }, MAX_OBSERVATIONS_PER_STATION);
807
+ touched.add(metar.station);
808
+ result.metars++;
809
+ }
810
+ }
811
+ result.stations = [...touched];
812
+ if (result.metars + result.tafs > 0) this.changed();
813
+ return result;
814
+ }
815
+ /** Hapus semua data manual (seluruh stasiun, atau satu stasiun). */
816
+ clearManual(station) {
817
+ let removed = false;
818
+ for (const store of [this.observations, this.forecasts]) {
819
+ for (const [icao, list] of store) {
820
+ if (station && icao !== station) continue;
821
+ const kept = list.filter((s) => !s.manual);
822
+ if (kept.length !== list.length) removed = true;
823
+ store.set(icao, kept);
824
+ }
825
+ }
826
+ if (removed) this.changed();
827
+ }
828
+ /** Hapus semua data (otomatis dan manual). */
829
+ clear() {
830
+ this.observations.clear();
831
+ this.forecasts.clear();
832
+ this.changed();
833
+ }
834
+ hasManual() {
835
+ for (const store of [this.observations, this.forecasts]) {
836
+ for (const list of store.values()) if (list.some((s) => s.manual)) return true;
837
+ }
838
+ return false;
839
+ }
840
+ stations() {
841
+ return [.../* @__PURE__ */ new Set([...this.observations.keys(), ...this.forecasts.keys()])].filter(
842
+ (icao) => (this.observations.get(icao)?.length ?? 0) + (this.forecasts.get(icao)?.length ?? 0) > 0
843
+ );
844
+ }
845
+ onChange(listener) {
846
+ this.listeners.add(listener);
847
+ return () => this.listeners.delete(listener);
848
+ }
849
+ /** Cuaca satu stasiun pada waktu `at`; null bila tidak ada data. */
850
+ snapshotAt(station, at, options = {}) {
851
+ return animationSnapshot({
852
+ observation: this.pickObservation(station, at, options.maxObservationAgeMs),
853
+ forecast: this.pickForecast(station, at),
854
+ at,
855
+ maxObservationAgeMs: options.maxObservationAgeMs,
856
+ includeProbable: options.includeProbable
857
+ });
858
+ }
859
+ /** Rentang waktu dan pembagian periode (BASE/BECMG/TEMPO/FM) dari TAF, untuk timeline. */
860
+ timeline() {
861
+ const segments = [];
862
+ const observations = [];
863
+ let from = null;
864
+ let to = null;
865
+ const widen = (a, b) => {
866
+ from = from === null ? a.getTime() : Math.min(from, a.getTime());
867
+ to = to === null ? b.getTime() : Math.max(to, b.getTime());
868
+ };
869
+ for (const [station, list] of this.forecasts) {
870
+ const taf = this.latest(list, (t) => t.issuedAt);
871
+ if (!taf || taf.cancelled) continue;
872
+ widen(taf.validFrom, taf.validTo);
873
+ for (const period of taf.periods) {
874
+ if (period.change === "BASE") continue;
875
+ const label = period.change === "FM" ? `FM ${hhmm(period.from)}` : `${period.probability ? `PROB${period.probability} ` : ""}${period.change} ${hhmm(period.from)}-${hhmm(period.to)}`;
876
+ segments.push({ station, change: period.change, from: period.from, to: period.to, label });
877
+ }
878
+ }
879
+ for (const list of this.observations.values()) {
880
+ for (const source of list) {
881
+ observations.push(source.report.observedAt);
882
+ widen(source.report.observedAt, source.report.observedAt);
883
+ }
884
+ }
885
+ if (from === null || to === null) return null;
886
+ observations.sort((a, b) => a.getTime() - b.getTime());
887
+ return { from: new Date(from), to: new Date(to), segments, observations };
888
+ }
889
+ /**
890
+ * Medan angin, awan, hujan, dan suhu pada waktu `at`, dari semua stasiun yang punya data dan
891
+ * koordinat, diinterpolasi ke grid. Mengembalikan null bila tidak ada stasiun yang bisa dipakai.
892
+ */
893
+ fieldsAt(at, grid, options = {}) {
894
+ const winds = [];
895
+ const clouds = [];
896
+ const rains = [];
897
+ const temperatures = [];
898
+ const used = [];
899
+ for (const station of this.stations()) {
900
+ const place = this.locate(station);
901
+ if (!place) continue;
902
+ const snapshot = this.snapshotAt(station, at, options);
903
+ if (!snapshot) continue;
904
+ used.push(station);
905
+ const { state } = snapshot;
906
+ winds.push({
907
+ lat: place.lat,
908
+ lon: place.lon,
909
+ speedKt: state.wind.speedKt,
910
+ directionDeg: state.wind.fromDeg
911
+ });
912
+ clouds.push({ lat: place.lat, lon: place.lon, value: state.clouds.total * 100 });
913
+ rains.push({
914
+ lat: place.lat,
915
+ lon: place.lon,
916
+ value: state.precipitation ? rainRate(state.precipitation.intensity) : 0
917
+ });
918
+ const observed = this.pickObservation(station, at);
919
+ if (observed?.temperatureC !== null && observed?.temperatureC !== void 0) {
920
+ temperatures.push({ lat: place.lat, lon: place.lon, value: observed.temperatureC });
921
+ }
922
+ }
923
+ if (used.length === 0) return null;
924
+ return {
925
+ at,
926
+ stations: used,
927
+ wind: windGridFromStations(winds, grid),
928
+ cloud: interpolateScalar(clouds, grid),
929
+ rain: interpolateScalar(rains, grid),
930
+ temperature: temperatures.length > 0 ? interpolateScalar(temperatures, grid) : null
931
+ };
932
+ }
933
+ // ---- internal -------------------------------------------------------------
934
+ changed() {
935
+ this.version++;
936
+ for (const listener of this.listeners) listener();
937
+ }
938
+ add(store, station, source, limit) {
939
+ const list = store.get(station) ?? [];
940
+ const key = (s) => {
941
+ const report = s.report;
942
+ return (report.observedAt ?? report.issuedAt)?.getTime() ?? 0;
943
+ };
944
+ const next = list.filter((s) => !(s.manual === source.manual && key(s) === key(source)));
945
+ next.push(source);
946
+ next.sort((a, b) => key(a) - key(b));
947
+ store.set(station, next.slice(-limit));
948
+ }
949
+ latest(list, timeOf) {
950
+ const pool = list.some((s) => s.manual) ? list.filter((s) => s.manual) : list;
951
+ let best = null;
952
+ for (const source of pool) {
953
+ if (!best || timeOf(source.report).getTime() >= timeOf(best.report).getTime()) best = source;
954
+ }
955
+ return best?.report ?? null;
956
+ }
957
+ pickObservation(station, at, maxAgeMs = 3 * 36e5) {
958
+ const list = this.observations.get(station) ?? [];
959
+ const usable = (s) => {
960
+ const age = at.getTime() - s.report.observedAt.getTime();
961
+ return age >= 0 && age <= maxAgeMs;
962
+ };
963
+ const manual = list.filter((s) => s.manual && usable(s));
964
+ const pool = manual.length > 0 ? manual : list.filter(usable);
965
+ let best = null;
966
+ for (const source of pool) {
967
+ if (!best || source.report.observedAt.getTime() >= best.report.observedAt.getTime()) best = source;
968
+ }
969
+ if (best) return best.report;
970
+ const before = list.filter((s) => s.report.observedAt.getTime() <= at.getTime());
971
+ return before.length > 0 ? before[before.length - 1].report : null;
972
+ }
973
+ pickForecast(station, at) {
974
+ const list = this.forecasts.get(station) ?? [];
975
+ const covers = (s) => !s.report.cancelled && at.getTime() >= s.report.validFrom.getTime() && at.getTime() < s.report.validTo.getTime();
976
+ const manual = list.filter((s) => s.manual && covers(s));
977
+ const pool = manual.length > 0 ? manual : list.filter(covers);
978
+ if (pool.length === 0) return null;
979
+ return this.latest(pool, (t) => t.issuedAt);
980
+ }
981
+ };
982
+
769
983
  // src/index.ts
770
984
  function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
771
985
  const reports = [];
@@ -776,6 +990,7 @@ function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
776
990
  return reports;
777
991
  }
778
992
  export {
993
+ WeatherStore,
779
994
  animationFromConditions,
780
995
  animationSnapshot,
781
996
  blendConditions,
@@ -0,0 +1,91 @@
1
+ import { type AnimationSnapshot } from "./animation.js";
2
+ import { type GeoPoint, type GridBounds, type ScalarGrid, type WindGrid } from "./grid.js";
3
+ import type { TafChange } from "./types.js";
4
+ export type Locate = (icao: string) => GeoPoint | undefined;
5
+ export interface IngestOptions {
6
+ /** Waktu pesan diterima (menentukan bulan/tahun laporan). Bawaan: sekarang. */
7
+ receivedAt?: Date;
8
+ /** Data manual selalu menimpa data otomatis untuk stasiun dan waktu yang sama. */
9
+ manual?: boolean;
10
+ }
11
+ export interface IngestResult {
12
+ metars: number;
13
+ tafs: number;
14
+ /** Laporan yang dikenali kata kuncinya tetapi tidak terbaca. */
15
+ skipped: number;
16
+ stations: string[];
17
+ }
18
+ export interface TimelineSegment {
19
+ station: string;
20
+ change: TafChange;
21
+ from: Date;
22
+ to: Date;
23
+ label: string;
24
+ }
25
+ export interface Timeline {
26
+ from: Date;
27
+ to: Date;
28
+ segments: TimelineSegment[];
29
+ /** Waktu pengamatan (METAR/SPECI) yang ada. */
30
+ observations: Date[];
31
+ }
32
+ export interface WeatherFields {
33
+ at: Date;
34
+ /** Stasiun yang punya data pada waktu itu. */
35
+ stations: string[];
36
+ wind: WindGrid;
37
+ /** Tutupan awan 0-100. */
38
+ cloud: ScalarGrid;
39
+ /** Curah hujan (mm/jam), perkiraan dari intensitas pada laporan. */
40
+ rain: ScalarGrid;
41
+ /** Suhu (°C); null bila tidak ada pengamatan bersuhu. */
42
+ temperature: ScalarGrid | null;
43
+ }
44
+ export interface FieldGridOptions {
45
+ bounds: GridBounds;
46
+ nx: number;
47
+ ny: number;
48
+ maxDistanceKm?: number;
49
+ }
50
+ /**
51
+ * Menampung laporan cuaca (METAR, SPECI, TAF) dari mana pun, lalu menjawab "bagaimana cuacanya di
52
+ * waktu T": per stasiun maupun sebagai medan grid. Data manual selalu menimpa data otomatis.
53
+ */
54
+ export declare class WeatherStore {
55
+ private readonly locate;
56
+ private readonly observations;
57
+ private readonly forecasts;
58
+ private readonly listeners;
59
+ /** Naik tiap data berubah; berguna untuk memicu render ulang. */
60
+ version: number;
61
+ constructor(locate: Locate);
62
+ /** Terima pesan mentah (laporan polos, buletin, atau pesan AFTN; boleh banyak laporan). */
63
+ ingest(message: string, options?: IngestOptions): IngestResult;
64
+ /** Hapus semua data manual (seluruh stasiun, atau satu stasiun). */
65
+ clearManual(station?: string): void;
66
+ /** Hapus semua data (otomatis dan manual). */
67
+ clear(): void;
68
+ hasManual(): boolean;
69
+ stations(): string[];
70
+ onChange(listener: () => void): () => void;
71
+ /** Cuaca satu stasiun pada waktu `at`; null bila tidak ada data. */
72
+ snapshotAt(station: string, at: Date, options?: {
73
+ maxObservationAgeMs?: number;
74
+ includeProbable?: boolean;
75
+ }): AnimationSnapshot | null;
76
+ /** Rentang waktu dan pembagian periode (BASE/BECMG/TEMPO/FM) dari TAF, untuk timeline. */
77
+ timeline(): Timeline | null;
78
+ /**
79
+ * Medan angin, awan, hujan, dan suhu pada waktu `at`, dari semua stasiun yang punya data dan
80
+ * koordinat, diinterpolasi ke grid. Mengembalikan null bila tidak ada stasiun yang bisa dipakai.
81
+ */
82
+ fieldsAt(at: Date, grid: FieldGridOptions, options?: {
83
+ includeProbable?: boolean;
84
+ }): WeatherFields | null;
85
+ private changed;
86
+ private add;
87
+ private latest;
88
+ private pickObservation;
89
+ private pickForecast;
90
+ }
91
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.test.d.ts","sourceRoot":"","sources":["../src/store.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeroswarm/weather",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",