mostlyright 1.5.0 → 1.5.2
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/{chunk-OTJGBPL4.mjs → chunk-4RWHWFV3.mjs} +8 -4
- package/dist/chunk-4RWHWFV3.mjs.map +1 -0
- package/dist/{iem-asos-BPSHDERE.mjs → iem-asos-GEUKTQTM.mjs} +2 -2
- package/dist/index.bundle.mjs +76 -29
- package/dist/index.bundle.mjs.map +1 -1
- package/dist/index.global.js +81 -30
- package/dist/index.global.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-OTJGBPL4.mjs.map +0 -1
- /package/dist/{iem-asos-BPSHDERE.mjs.map → iem-asos-GEUKTQTM.mjs.map} +0 -0
|
@@ -72,6 +72,11 @@ function splitIso(iso) {
|
|
|
72
72
|
Number.parseInt(parts[2], 10)
|
|
73
73
|
];
|
|
74
74
|
}
|
|
75
|
+
function addOneDay(iso) {
|
|
76
|
+
const [y, m, d] = splitIso(iso);
|
|
77
|
+
const next = new Date(Date.UTC(y, m - 1, d + 1));
|
|
78
|
+
return next.toISOString().slice(0, 10);
|
|
79
|
+
}
|
|
75
80
|
async function downloadIemAsos(stationCode, start, end, opts = {}) {
|
|
76
81
|
const reportType = opts.reportType ?? 3;
|
|
77
82
|
if (!VALID_REPORT_TYPES.has(reportType)) {
|
|
@@ -81,10 +86,9 @@ async function downloadIemAsos(stationCode, start, end, opts = {}) {
|
|
|
81
86
|
if (start > end) {
|
|
82
87
|
return [];
|
|
83
88
|
}
|
|
84
|
-
const
|
|
85
|
-
const chunks = yearlyChunksExclusiveEnd(normalizedStart, end);
|
|
89
|
+
const chunks = opts.exactStart ? [[start, addOneDay(end)]] : yearlyChunksExclusiveEnd(`${start.slice(0, 4)}-01-01`, end);
|
|
86
90
|
const politenessMs = opts.politenessMs ?? IEM_POLITE_DELAY_MS;
|
|
87
|
-
const { reportType: _rtDrop, politenessMs: _pmDrop, ...fetchOpts } = opts;
|
|
91
|
+
const { reportType: _rtDrop, politenessMs: _pmDrop, exactStart: _esDrop, ...fetchOpts } = opts;
|
|
88
92
|
const out = [];
|
|
89
93
|
for (const [chunkStart, chunkEnd] of chunks) {
|
|
90
94
|
const url = buildIemUrl(stationCode, chunkStart, chunkEnd, reportType);
|
|
@@ -105,4 +109,4 @@ export {
|
|
|
105
109
|
buildIemUrl,
|
|
106
110
|
downloadIemAsos
|
|
107
111
|
};
|
|
108
|
-
//# sourceMappingURL=chunk-
|
|
112
|
+
//# sourceMappingURL=chunk-4RWHWFV3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../weather/src/_fetchers/_iem_chunks.ts","../../weather/src/_fetchers/iem-asos.ts"],"sourcesContent":["// Shared calendar-year chunkers for IEM fetchers.\n//\n// Byte-faithful TS port of\n// `packages/weather/src/mostlyright/weather/_fetchers/_iem_chunks.py::yearly_chunks_exclusive_end`\n// (mostlyright PR #85 commit cf9eb85, 2026-05-12; Pattern 1).\n//\n// Only the EXCLUSIVE-end variant is ported here — the IEM ASOS download\n// loop (`iem-asos.ts`) is the sole TS-side consumer, and IEM's `day2`\n// query parameter is exclusive. `yearly_chunks_inclusive` (used by the\n// Python forecast / climate paths) is NOT ported in TS-W2.\n//\n// Leap-year safety: the advance step uses calendar arithmetic\n// (`date(year+1, 1, 1)`), NOT `+365 days`. The latter silently drops Feb 29\n// in leap years and walks the calendar boundary by one day every leap year\n// — PR #85's primary anti-pattern.\n//\n// Date representation: ISO 8601 date strings (\"YYYY-MM-DD\"). Strings sort\n// lexicographically equivalent to calendar order and round-trip cleanly;\n// the JS `Date` constructor is deliberately avoided because its arithmetic\n// silently shifts to local-time, which would poison cache keys at midnight\n// boundaries for non-UTC hosts (Python's `_iem_chunks.py` module docstring\n// references this class of bug).\n\n/**\n * ISO 8601 date string, no time component. Format: `YYYY-MM-DD`.\n *\n * Brand-style alias — at runtime this is a plain `string`. Use\n * {@link parseIsoDate} when you need the parsed year/month/day integers.\n */\nexport type IsoDate = string;\n\nconst ISO_DATE_RE = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n\ninterface YMD {\n readonly year: number;\n readonly month: number;\n readonly day: number;\n}\n\n/**\n * Parse an ISO date string into year/month/day integers. Throws on\n * malformed input. The validation is intentionally minimal — chunker\n * inputs come from the fetcher's normalization layer, which is the\n * defensive boundary. Calendar-validity (e.g. Feb 30) is NOT checked\n * here; the caller has already validated.\n */\nfunction parseIsoDate(value: IsoDate): YMD {\n const m = ISO_DATE_RE.exec(value);\n if (m === null) {\n throw new Error(`Invalid ISO date: ${JSON.stringify(value)}; expected \"YYYY-MM-DD\"`);\n }\n // Indices 1-3 always present when the regex matches.\n return {\n year: Number.parseInt(m[1] as string, 10),\n month: Number.parseInt(m[2] as string, 10),\n day: Number.parseInt(m[3] as string, 10),\n };\n}\n\n/**\n * Format integer year/month/day as an ISO date string. Month and day are\n * zero-padded to 2 digits to preserve lexicographic order.\n */\nfunction formatIsoDate(year: number, month: number, day: number): IsoDate {\n const mm = month < 10 ? `0${month}` : String(month);\n const dd = day < 10 ? `0${day}` : String(day);\n return `${year}-${mm}-${dd}`;\n}\n\n/**\n * Range split into per-calendar-year EXCLUSIVE-end chunks\n * (Jan 1 of next year).\n *\n * Properties:\n * - The first chunk's start is `max(date(start.year, 1, 1), start)` — i.e.\n * the caller's actual start for the first chunk, NOT Jan 1. The\n * `current = date(start.year, 1, 1)` initialization ensures the loop\n * visits every calendar-year boundary regardless of the caller's start.\n * - Every chunk's end is `date(year+1, 1, 1)` — the IEM `day2`-exclusive\n * convention for \"include all of `year`\".\n * - Leap-year safe: advance via `date(year+1, 1, 1)`, NOT `+365 days`.\n * - Reversed range (`start > end` by lexicographic compare) returns `[]`\n * without throwing — higher layers iterate the list directly.\n *\n * Byte-faithful with the Python helper used by `iem_asos.download_iem_asos`.\n */\nexport function yearlyChunksExclusiveEnd(\n start: IsoDate,\n end: IsoDate,\n): ReadonlyArray<readonly [IsoDate, IsoDate]> {\n // Reversed-range short-circuit (lexicographic compare is equivalent to\n // calendar compare for YYYY-MM-DD strings of identical shape). Mirrors\n // Python L66-67 (`if start > end: return []`).\n if (start > end) {\n return [];\n }\n\n const { year: startYear } = parseIsoDate(start);\n const { year: endYear } = parseIsoDate(end);\n\n const chunks: Array<readonly [IsoDate, IsoDate]> = [];\n // Match Python `current = date(start.year, 1, 1)` initialization.\n let currentYear = startYear;\n while (currentYear <= endYear) {\n const currentYearStart = formatIsoDate(currentYear, 1, 1);\n // Match Python `chunk_start = max(current, start)`. For the first\n // iteration this clamps to the caller's `start`; for subsequent\n // iterations `currentYearStart` is always greater.\n const chunkStart: IsoDate = currentYearStart > start ? currentYearStart : start;\n // CRITICAL: leap-year safe via calendar arithmetic, NOT `+365 days`.\n const nextYearFirst: IsoDate = formatIsoDate(currentYear + 1, 1, 1);\n chunks.push([chunkStart, nextYearFirst]);\n currentYear += 1;\n }\n return chunks;\n}\n","// IEM ASOS historical METAR fetcher — yearly-chunked CSV downloads.\n//\n// Byte-faithful TS port of Python\n// `packages/weather/src/mostlyright/weather/_fetchers/iem_asos.py::download_iem_asos`,\n// with the following deliberate adaptations:\n//\n// 1. No disk cache. Python writes per-station CSVs under\n// `dest_dir/{station}/iem_*.csv`; the TS port returns the CSV body\n// in-memory via `{ chunkStart, chunkEnd, csv }`. The disk-cache layer\n// (PERF-02 partial-namespace + cache-poisoning paths) lands in TS-W3 —\n// it's intentionally out of scope here. Drop ALL filename / `skip_cache`\n// / `today_utc` partial-namespace logic.\n// 2. Station validation at the URL boundary uses an inline regex\n// (`^[A-Z]{3,4}$`) matching `iem-cli.ts`'s `validateIcao` pattern. The\n// Python `validate_icao_for_path` from `_internal/_bounds.py` is a\n// path-traversal guard — there's no path here in TS-W2, so the regex\n// check is the equivalent defense-in-depth measure.\n//\n// URL shape, start-normalization (caller's `start` clamped to\n// `date(start.year, 1, 1)` for cache idempotence under per-month callers),\n// reversed-range short-circuit (`start > end → []`), report_type guard\n// ({3, 4}), and 1-sec polite delay are byte-faithful.\n//\n// CORS posture: OPEN — IEM emits `Access-Control-Allow-Origin: *` per\n// `.planning/research/TS-CORS-MATRIX.md` §IEM-ASOS. Works in browsers,\n// Node 20+, Cloudflare Workers, Deno.\n\nimport { fetchWithRetry } from \"@mostlyrightmd/core\";\nimport type { FetchWithRetryOptions } from \"@mostlyrightmd/core\";\n\nimport { type IsoDate, yearlyChunksExclusiveEnd } from \"./_iem_chunks.js\";\n\n/** IEM ASOS request endpoint. Mirrors Python `IEM_BASE_URL`. */\nexport const IEM_BASE_URL = \"https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py\";\n\n/**\n * Polite delay (ms) between consecutive IEM HTTP requests. Mirrors Python\n * `IEM_POLITE_DELAY = 1.0` (s) — IEM runs on a university server and\n * documents a 1-sec/IP throttle (see .planning/research/SOURCE-LIMITS.md).\n */\nexport const IEM_POLITE_DELAY_MS = 1000;\n\n/**\n * Station code regex (3-4 uppercase letters). Mirrors the inline pattern\n * used by `iem-cli.ts::validateIcao` and the shared `STATION_CODE_RE` from\n * `@mostlyrightmd/core/internal/bounds`. Inlined here so the fetcher does not\n * transitively pull in the validators barrel — keeps the per-fetcher\n * dep graph narrow per the TS-W1 review pattern.\n */\nconst STATION_CODE_RE = /^[A-Z]{3,4}$/;\n\n/** Permitted IEM report_type values: 3 = METAR, 4 = SPECI. */\nconst VALID_REPORT_TYPES = new Set<number>([3, 4]);\n\n/**\n * One downloaded yearly chunk. The CSV body is forwarded verbatim from\n * the upstream response so the parser can run a downstream pass over it.\n */\nexport interface IemChunkResult {\n /** First day of this chunk's range (caller's start for chunk 0; Jan 1 for subsequent). */\n readonly chunkStart: IsoDate;\n /** EXCLUSIVE end of this chunk's range — Jan 1 of the following calendar year. */\n readonly chunkEnd: IsoDate;\n /** Raw CSV body returned by IEM (text/plain, comma-separated). */\n readonly csv: string;\n}\n\nexport interface DownloadIemAsosOptions extends FetchWithRetryOptions {\n /** `3` (METAR, default) or `4` (SPECI). */\n reportType?: 3 | 4;\n /**\n * Delay (ms) between successive chunk requests. Defaults to\n * {@link IEM_POLITE_DELAY_MS}. Set to `0` in unit tests.\n */\n politenessMs?: number;\n /**\n * When `true`, fetch the EXACT caller-supplied window — skip the Jan-1\n * start-normalization AND skip yearly chunking. Issues a single HTTP\n * request to IEM bounded by `[start, end+1day)` (IEM `day2` is exclusive).\n *\n * Use this for one-off short windows (e.g. `obs(strategy=\"exact_window\")`\n * — a few-day historical lookup). The default (`false`) preserves the\n * year-padded path needed for cache-idempotent multi-month / multi-year\n * archive fetches (`obs(strategy=\"warm_cache\")`, `research()`,\n * `dailyExtremes()`).\n *\n * Regression: GH #57 — without this flag, a 1-day call expanded into a\n * ~734 KB whole-calendar-year fetch and ~75× the payload trimmed in\n * memory downstream.\n */\n exactStart?: boolean;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction validateIcao(stationCode: string): void {\n if (typeof stationCode !== \"string\" || !STATION_CODE_RE.test(stationCode)) {\n throw new Error(\n `station_code=${JSON.stringify(\n stationCode,\n )} does not match STATION_CODE_RE (3-4 uppercase letters); refusing to use as URL component`,\n );\n }\n}\n\n/**\n * Build the IEM ASOS download URL for one (chunk, report_type) request.\n *\n * Param shape and ordering are byte-faithful to Python `_build_iem_url`:\n * station, data, tz, format, latlon, elev, missing, trace, direct,\n * report_type, year1, month1, day1, year2, month2, day2.\n *\n * Month/day are emitted WITHOUT zero-padding (Python uses bare `{start.month}`,\n * not `{start.month:02d}`) — preserve byte-equivalence on URL snapshots.\n *\n * `end` is the EXCLUSIVE end (already adjusted by the chunker to Jan 1 of\n * the following calendar year).\n */\nexport function buildIemUrl(\n stationCode: string,\n start: IsoDate,\n end: IsoDate,\n reportType: number,\n): string {\n // Parse ISO strings into ints (no Date — avoids local-TZ silent shifts).\n const [sy, sm, sd] = splitIso(start);\n const [ey, em, ed] = splitIso(end);\n const params = `station=${stationCode}&data=all&tz=Etc/UTC&format=comma&latlon=no&elev=no&missing=M&trace=T&direct=no&report_type=${reportType}&year1=${sy}&month1=${sm}&day1=${sd}&year2=${ey}&month2=${em}&day2=${ed}`;\n return `${IEM_BASE_URL}?${params}`;\n}\n\nfunction splitIso(iso: IsoDate): [number, number, number] {\n // The chunker has already validated; we just split.\n const parts = iso.split(\"-\");\n // Length-3 guaranteed if the chunker emitted this; defense in depth.\n if (parts.length !== 3) {\n throw new Error(`Invalid ISO date passed to buildIemUrl: ${JSON.stringify(iso)}`);\n }\n return [\n Number.parseInt(parts[0] as string, 10),\n Number.parseInt(parts[1] as string, 10),\n Number.parseInt(parts[2] as string, 10),\n ];\n}\n\n/**\n * Add one calendar day to an ISO `YYYY-MM-DD` date, handling month/year\n * overflow (Dec 31 → next-year Jan 1) and leap years correctly. Used by\n * the `exactStart` path to convert the caller's inclusive `end` into IEM's\n * exclusive `day2`.\n *\n * `Date.UTC(...)` builds a UTC timestamp (NOT subject to local-TZ shifts),\n * then `.toISOString().slice(0, 10)` extracts the UTC date string. Safe for\n * leap years: `addOneDay(\"2024-02-28\")` → `\"2024-02-29\"`.\n */\nfunction addOneDay(iso: IsoDate): IsoDate {\n const [y, m, d] = splitIso(iso);\n const next = new Date(Date.UTC(y, m - 1, d + 1));\n return next.toISOString().slice(0, 10) as IsoDate;\n}\n\n/**\n * Download yearly chunks of IEM ASOS data for one station, returning the\n * raw CSV bodies in chunker-natural order.\n *\n * The caller's inclusive `[start, end]` is normalized internally to\n * `[date(start.year, 1, 1), end]` and split into per-calendar-year\n * EXCLUSIVE-end chunks via {@link yearlyChunksExclusiveEnd}. Each chunk\n * fires one HTTP request to the IEM ASOS endpoint, with a polite delay\n * between successful responses (default {@link IEM_POLITE_DELAY_MS}).\n *\n * Errors propagate from `fetchWithRetry` — 4xx/5xx after retry exhaustion\n * are NOT swallowed (the IEM ASOS path is parity-critical; silent failure\n * would manifest as missing observation rows downstream).\n *\n * @throws If `reportType` is not `3` (METAR) or `4` (SPECI).\n * @throws If `stationCode` does not match `^[A-Z]{3,4}$` (path-traversal /\n * URL-injection defense).\n * @throws Whatever `fetchWithRetry` propagates on persistent network/HTTP\n * errors.\n */\nexport async function downloadIemAsos(\n stationCode: string,\n start: IsoDate,\n end: IsoDate,\n opts: DownloadIemAsosOptions = {},\n): Promise<ReadonlyArray<IemChunkResult>> {\n const reportType = opts.reportType ?? 3;\n if (!VALID_REPORT_TYPES.has(reportType)) {\n throw new Error(`report_type must be 3 (METAR) or 4 (SPECI), got ${reportType}`);\n }\n // Defense-in-depth: validate at the URL boundary BEFORE any HTTP.\n validateIcao(stationCode);\n\n // Reversed-range guard. Mirror Python L201-202: the chunker honors\n // `start > end → []`, but defensive short-circuit here avoids ever\n // computing the normalized year boundary for a malformed call.\n if (start > end) {\n return [];\n }\n\n // GH #57: `exactStart` opts out of Jan-1 widening + yearly chunking.\n // Issues a SINGLE byte-bounded request for `[start, end+1day exclusive]`\n // (IEM `day2` is exclusive). Used by `obs(strategy=\"exact_window\")` so a\n // 1-day historical call pulls ~1 day of bytes, not the whole calendar\n // year (~734 KB). The default path below preserves the year-padded shape\n // needed for cache-idempotent multi-month / archive callers (parity-\n // faithful with Python's `normalized_start = date(start.year, 1, 1)`).\n const chunks: ReadonlyArray<readonly [IsoDate, IsoDate]> = opts.exactStart\n ? [[start, addOneDay(end)] as const]\n : yearlyChunksExclusiveEnd(`${start.slice(0, 4)}-01-01`, end);\n\n const politenessMs = opts.politenessMs ?? IEM_POLITE_DELAY_MS;\n // Strip fetcher-specific opts from the bag forwarded to fetchWithRetry.\n const { reportType: _rtDrop, politenessMs: _pmDrop, exactStart: _esDrop, ...fetchOpts } = opts;\n void _rtDrop;\n void _pmDrop;\n void _esDrop;\n\n const out: IemChunkResult[] = [];\n for (const [chunkStart, chunkEnd] of chunks) {\n const url = buildIemUrl(stationCode, chunkStart, chunkEnd, reportType);\n const response = await fetchWithRetry(url, fetchOpts);\n const csv = await response.text();\n out.push({ chunkStart, chunkEnd, csv });\n // Polite delay AFTER each successful round-trip (mirror Python L224\n // which sleeps unconditionally). Skipping the trailing sleep on the\n // last chunk would be a micro-optimization not present in Python.\n if (politenessMs > 0) {\n await sleep(politenessMs);\n }\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
IEM_POLITE_DELAY_MS,
|
|
4
4
|
buildIemUrl,
|
|
5
5
|
downloadIemAsos
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-4RWHWFV3.mjs";
|
|
7
7
|
import "./chunk-L7OZMKOJ.mjs";
|
|
8
8
|
import "./chunk-J5LGTIGS.mjs";
|
|
9
9
|
export {
|
|
@@ -12,4 +12,4 @@ export {
|
|
|
12
12
|
buildIemUrl,
|
|
13
13
|
downloadIemAsos
|
|
14
14
|
};
|
|
15
|
-
//# sourceMappingURL=iem-asos-
|
|
15
|
+
//# sourceMappingURL=iem-asos-GEUKTQTM.mjs.map
|
package/dist/index.bundle.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
buildIemUrl,
|
|
5
5
|
downloadIemAsos,
|
|
6
6
|
yearlyChunksExclusiveEnd
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-4RWHWFV3.mjs";
|
|
8
8
|
import {
|
|
9
9
|
CLIMATE_REPORT_TYPE_PRIORITY,
|
|
10
10
|
DataAvailabilityError,
|
|
@@ -777,7 +777,7 @@ async function fetchIemLatest(station) {
|
|
|
777
777
|
const [{ fetchWithRetry: fetchWithRetry2 }, { STATION_CODE_RE: STATION_CODE_RE3 }, { buildIemUrl: buildIemUrl2 }, { parseIemCsv: parseIemCsv2 }] = await Promise.all([
|
|
778
778
|
import("./src-ENFDREPA.mjs"),
|
|
779
779
|
import("./bounds-KSTXL77E.mjs"),
|
|
780
|
-
import("./iem-asos-
|
|
780
|
+
import("./iem-asos-GEUKTQTM.mjs"),
|
|
781
781
|
import("./iem-IO2HIL5V.mjs")
|
|
782
782
|
]);
|
|
783
783
|
const icao = normalizeStation(station);
|
|
@@ -902,6 +902,26 @@ var IEM_MOS_URL = "https://mesonet.agron.iastate.edu/api/1/mos.json";
|
|
|
902
902
|
var SUPPORTED_MODELS = /* @__PURE__ */ new Set(["nbe", "gfs", "lav", "met", "ecm"]);
|
|
903
903
|
var KT_TO_MS = 0.5144444;
|
|
904
904
|
var NBE_CYCLE_CUTOVER = Date.UTC(2026, 5 - 1, 5, 0, 0, 0);
|
|
905
|
+
var MOS_FETCH_CONCURRENCY = 8;
|
|
906
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
907
|
+
const results = new Array(items.length);
|
|
908
|
+
let cursor = 0;
|
|
909
|
+
let failed = false;
|
|
910
|
+
async function worker() {
|
|
911
|
+
while (cursor < items.length && !failed) {
|
|
912
|
+
const index = cursor++;
|
|
913
|
+
try {
|
|
914
|
+
results[index] = await fn(items[index], index);
|
|
915
|
+
} catch (err) {
|
|
916
|
+
failed = true;
|
|
917
|
+
throw err;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const poolSize = Math.min(limit, items.length);
|
|
922
|
+
await Promise.all(Array.from({ length: poolSize }, () => worker()));
|
|
923
|
+
return results;
|
|
924
|
+
}
|
|
905
925
|
function runtimeHoursFor(model, fromDt, toDt) {
|
|
906
926
|
if (model !== "nbe") return [0, 6, 12, 18];
|
|
907
927
|
const fromMs = fromDt.getTime();
|
|
@@ -985,31 +1005,37 @@ async function iemMosForecasts(station, fromDate, toDate, opts = {}) {
|
|
|
985
1005
|
const toDt = parseIsoDate(toDate, true);
|
|
986
1006
|
const hours = runtimeHoursFor(model, fromDt, toDt);
|
|
987
1007
|
const retrievedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
988
|
-
const rows = [];
|
|
989
1008
|
const dayMs = 864e5;
|
|
1009
|
+
const urls = [];
|
|
990
1010
|
for (let day = fromDt.getTime(); day <= toDt.getTime(); day += dayMs) {
|
|
991
1011
|
for (const h of hours) {
|
|
992
1012
|
const rt = new Date(day);
|
|
993
1013
|
rt.setUTCHours(h, 0, 0, 0);
|
|
994
1014
|
if (rt < fromDt || rt > toDt) continue;
|
|
995
|
-
|
|
996
|
-
station
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (!resp.ok) {
|
|
1003
|
-
throw new Error(`iemMosForecasts: HTTP ${resp.status} on ${url}`);
|
|
1004
|
-
}
|
|
1005
|
-
const payload = await resp.json();
|
|
1006
|
-
for (const raw of payload.data ?? []) {
|
|
1007
|
-
const projected = parseRow(raw, station, model, retrievedAt);
|
|
1008
|
-
if (projected !== null) rows.push(projected);
|
|
1009
|
-
}
|
|
1015
|
+
urls.push(
|
|
1016
|
+
`${IEM_MOS_URL}?station=${encodeURIComponent(
|
|
1017
|
+
station
|
|
1018
|
+
)}&model=${encodeURIComponent(model.toUpperCase())}&runtime=${encodeURIComponent(
|
|
1019
|
+
rt.toISOString()
|
|
1020
|
+
)}`
|
|
1021
|
+
);
|
|
1010
1022
|
}
|
|
1011
1023
|
}
|
|
1012
|
-
|
|
1024
|
+
const perCycle = await mapWithConcurrency(urls, MOS_FETCH_CONCURRENCY, async (url) => {
|
|
1025
|
+
const resp = await fetchFn(url);
|
|
1026
|
+
if (resp.status === 404) return [];
|
|
1027
|
+
if (!resp.ok) {
|
|
1028
|
+
throw new Error(`iemMosForecasts: HTTP ${resp.status} on ${url}`);
|
|
1029
|
+
}
|
|
1030
|
+
const payload = await resp.json();
|
|
1031
|
+
const out = [];
|
|
1032
|
+
for (const raw of payload.data ?? []) {
|
|
1033
|
+
const projected = parseRow(raw, station, model, retrievedAt);
|
|
1034
|
+
if (projected !== null) out.push(projected);
|
|
1035
|
+
}
|
|
1036
|
+
return out;
|
|
1037
|
+
});
|
|
1038
|
+
return perCycle.flat();
|
|
1013
1039
|
}
|
|
1014
1040
|
|
|
1015
1041
|
// ../weather/src/forecasts/nwp-stub.ts
|
|
@@ -1284,6 +1310,15 @@ function maybeNumber2(value) {
|
|
|
1284
1310
|
const n = typeof value === "number" ? value : Number(value);
|
|
1285
1311
|
return Number.isFinite(n) ? n : null;
|
|
1286
1312
|
}
|
|
1313
|
+
function maybeInt(value) {
|
|
1314
|
+
const n = maybeNumber2(value);
|
|
1315
|
+
if (n === null) return null;
|
|
1316
|
+
const floor = Math.floor(n);
|
|
1317
|
+
const diff = n - floor;
|
|
1318
|
+
if (diff < 0.5) return floor;
|
|
1319
|
+
if (diff > 0.5) return floor + 1;
|
|
1320
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
1321
|
+
}
|
|
1287
1322
|
function pickHourlyValue(hourly, key, isPreviousRuns, idx) {
|
|
1288
1323
|
const arr = isPreviousRuns ? hourly[`${key}_previous_day1`] ?? hourly[key] : hourly[key];
|
|
1289
1324
|
if (!Array.isArray(arr) || idx >= arr.length) return null;
|
|
@@ -1311,13 +1346,16 @@ async function openMeteoForecasts(station, fromDate, toDate, opts = {}) {
|
|
|
1311
1346
|
const params = new URLSearchParams();
|
|
1312
1347
|
params.set("latitude", String(lat));
|
|
1313
1348
|
params.set("longitude", String(lon));
|
|
1314
|
-
params.set("start_date", fromDate);
|
|
1315
|
-
params.set("end_date", toDate);
|
|
1316
1349
|
params.set("hourly", buildHourlyParam(endpoint));
|
|
1317
1350
|
params.set("models", model);
|
|
1318
1351
|
params.set("timezone", "UTC");
|
|
1319
|
-
if (endpoint === OPEN_METEO_SINGLE_RUNS_URL
|
|
1320
|
-
|
|
1352
|
+
if (endpoint === OPEN_METEO_SINGLE_RUNS_URL) {
|
|
1353
|
+
if (opts.issuedAt) {
|
|
1354
|
+
params.set("run", opts.issuedAt);
|
|
1355
|
+
}
|
|
1356
|
+
} else {
|
|
1357
|
+
params.set("start_date", fromDate);
|
|
1358
|
+
params.set("end_date", toDate);
|
|
1321
1359
|
}
|
|
1322
1360
|
const fetchFn = opts.fetchFn ?? fetch;
|
|
1323
1361
|
const url = `${endpoint}?${params.toString()}`;
|
|
@@ -1376,24 +1414,32 @@ async function openMeteoForecasts(station, fromDate, toDate, opts = {}) {
|
|
|
1376
1414
|
dewPointC: maybeNumber2(pickHourlyValue(h, "dew_point_2m", isPrev, i)),
|
|
1377
1415
|
apparentTempC: maybeNumber2(pickHourlyValue(h, "apparent_temperature", isPrev, i)),
|
|
1378
1416
|
windSpeedMs: maybeNumber2(pickHourlyValue(h, "wind_speed_10m", isPrev, i)),
|
|
1379
|
-
windDirDeg:
|
|
1417
|
+
windDirDeg: maybeInt(pickHourlyValue(h, "wind_direction_10m", isPrev, i)),
|
|
1380
1418
|
windGustsMs: maybeNumber2(pickHourlyValue(h, "wind_gusts_10m", isPrev, i)),
|
|
1381
1419
|
precipProbability: popPct === null ? null : popPct / 100,
|
|
1382
1420
|
precipitationMm: maybeNumber2(pickHourlyValue(h, "precipitation", isPrev, i)),
|
|
1383
|
-
cloudCoverPct:
|
|
1421
|
+
cloudCoverPct: maybeInt(pickHourlyValue(h, "cloud_cover", isPrev, i)),
|
|
1384
1422
|
surfacePressureHpa: maybeNumber2(pickHourlyValue(h, "surface_pressure", isPrev, i)),
|
|
1385
1423
|
pressureMslHpa: maybeNumber2(pickHourlyValue(h, "pressure_msl", isPrev, i)),
|
|
1386
1424
|
shortwaveRadiationWm2: maybeNumber2(pickHourlyValue(h, "shortwave_radiation", isPrev, i)),
|
|
1387
1425
|
directRadiationWm2: maybeNumber2(pickHourlyValue(h, "direct_radiation", isPrev, i)),
|
|
1388
1426
|
capeJkg: maybeNumber2(pickHourlyValue(h, "cape", isPrev, i)),
|
|
1389
|
-
freezingLevelM:
|
|
1427
|
+
freezingLevelM: maybeInt(pickHourlyValue(h, "freezing_level_height", isPrev, i)),
|
|
1390
1428
|
snowDepthM: maybeNumber2(pickHourlyValue(h, "snow_depth", isPrev, i)),
|
|
1391
|
-
visibilityM:
|
|
1392
|
-
weatherCode:
|
|
1429
|
+
visibilityM: maybeInt(pickHourlyValue(h, "visibility", isPrev, i)),
|
|
1430
|
+
weatherCode: maybeInt(pickHourlyValue(h, "weather_code", isPrev, i)),
|
|
1393
1431
|
source,
|
|
1394
1432
|
retrievedAt
|
|
1395
1433
|
});
|
|
1396
1434
|
}
|
|
1435
|
+
if (source === "open_meteo.single_run" && rows.length > 0) {
|
|
1436
|
+
const loMs = Date.parse(`${fromDate}T00:00:00Z`);
|
|
1437
|
+
const hiMs = Date.parse(`${toDate}T00:00:00Z`) + 864e5;
|
|
1438
|
+
return rows.filter((r) => {
|
|
1439
|
+
const v = Date.parse(r.validAt);
|
|
1440
|
+
return v >= loMs && v < hiMs;
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1397
1443
|
return rows;
|
|
1398
1444
|
}
|
|
1399
1445
|
|
|
@@ -3298,7 +3344,8 @@ async function fetchIemForWindow(station, fromDate, toDate, resolvedStrategy) {
|
|
|
3298
3344
|
if (resolvedStrategy === "exact_window") {
|
|
3299
3345
|
const chunks = await downloadIemAsos(station, fromDate, toDate, {
|
|
3300
3346
|
reportType: 3,
|
|
3301
|
-
politenessMs: 1e3
|
|
3347
|
+
politenessMs: 1e3,
|
|
3348
|
+
exactStart: true
|
|
3302
3349
|
});
|
|
3303
3350
|
for (const chunk of chunks) {
|
|
3304
3351
|
const rows = parseIemCsv(chunk.csv, { observationTypeOverride: "METAR" });
|