mostlyright 1.5.1 → 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 +61 -25
- package/dist/index.bundle.mjs.map +1 -1
- package/dist/index.global.js +66 -26
- 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;
|
|
@@ -1379,20 +1414,20 @@ async function openMeteoForecasts(station, fromDate, toDate, opts = {}) {
|
|
|
1379
1414
|
dewPointC: maybeNumber2(pickHourlyValue(h, "dew_point_2m", isPrev, i)),
|
|
1380
1415
|
apparentTempC: maybeNumber2(pickHourlyValue(h, "apparent_temperature", isPrev, i)),
|
|
1381
1416
|
windSpeedMs: maybeNumber2(pickHourlyValue(h, "wind_speed_10m", isPrev, i)),
|
|
1382
|
-
windDirDeg:
|
|
1417
|
+
windDirDeg: maybeInt(pickHourlyValue(h, "wind_direction_10m", isPrev, i)),
|
|
1383
1418
|
windGustsMs: maybeNumber2(pickHourlyValue(h, "wind_gusts_10m", isPrev, i)),
|
|
1384
1419
|
precipProbability: popPct === null ? null : popPct / 100,
|
|
1385
1420
|
precipitationMm: maybeNumber2(pickHourlyValue(h, "precipitation", isPrev, i)),
|
|
1386
|
-
cloudCoverPct:
|
|
1421
|
+
cloudCoverPct: maybeInt(pickHourlyValue(h, "cloud_cover", isPrev, i)),
|
|
1387
1422
|
surfacePressureHpa: maybeNumber2(pickHourlyValue(h, "surface_pressure", isPrev, i)),
|
|
1388
1423
|
pressureMslHpa: maybeNumber2(pickHourlyValue(h, "pressure_msl", isPrev, i)),
|
|
1389
1424
|
shortwaveRadiationWm2: maybeNumber2(pickHourlyValue(h, "shortwave_radiation", isPrev, i)),
|
|
1390
1425
|
directRadiationWm2: maybeNumber2(pickHourlyValue(h, "direct_radiation", isPrev, i)),
|
|
1391
1426
|
capeJkg: maybeNumber2(pickHourlyValue(h, "cape", isPrev, i)),
|
|
1392
|
-
freezingLevelM:
|
|
1427
|
+
freezingLevelM: maybeInt(pickHourlyValue(h, "freezing_level_height", isPrev, i)),
|
|
1393
1428
|
snowDepthM: maybeNumber2(pickHourlyValue(h, "snow_depth", isPrev, i)),
|
|
1394
|
-
visibilityM:
|
|
1395
|
-
weatherCode:
|
|
1429
|
+
visibilityM: maybeInt(pickHourlyValue(h, "visibility", isPrev, i)),
|
|
1430
|
+
weatherCode: maybeInt(pickHourlyValue(h, "weather_code", isPrev, i)),
|
|
1396
1431
|
source,
|
|
1397
1432
|
retrievedAt
|
|
1398
1433
|
});
|
|
@@ -3309,7 +3344,8 @@ async function fetchIemForWindow(station, fromDate, toDate, resolvedStrategy) {
|
|
|
3309
3344
|
if (resolvedStrategy === "exact_window") {
|
|
3310
3345
|
const chunks = await downloadIemAsos(station, fromDate, toDate, {
|
|
3311
3346
|
reportType: 3,
|
|
3312
|
-
politenessMs: 1e3
|
|
3347
|
+
politenessMs: 1e3,
|
|
3348
|
+
exactStart: true
|
|
3313
3349
|
});
|
|
3314
3350
|
for (const chunk of chunks) {
|
|
3315
3351
|
const rows = parseIemCsv(chunk.csv, { observationTypeOverride: "METAR" });
|