mostlyright 0.1.0-rc.7

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,108 @@
1
+ import {
2
+ fetchWithRetry
3
+ } from "./chunk-WYZFDCNR.mjs";
4
+
5
+ // ../weather/src/_fetchers/_iem_chunks.ts
6
+ var ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
7
+ function parseIsoDate(value) {
8
+ const m = ISO_DATE_RE.exec(value);
9
+ if (m === null) {
10
+ throw new Error(`Invalid ISO date: ${JSON.stringify(value)}; expected "YYYY-MM-DD"`);
11
+ }
12
+ return {
13
+ year: Number.parseInt(m[1], 10),
14
+ month: Number.parseInt(m[2], 10),
15
+ day: Number.parseInt(m[3], 10)
16
+ };
17
+ }
18
+ function formatIsoDate(year, month, day) {
19
+ const mm = month < 10 ? `0${month}` : String(month);
20
+ const dd = day < 10 ? `0${day}` : String(day);
21
+ return `${year}-${mm}-${dd}`;
22
+ }
23
+ function yearlyChunksExclusiveEnd(start, end) {
24
+ if (start > end) {
25
+ return [];
26
+ }
27
+ const { year: startYear } = parseIsoDate(start);
28
+ const { year: endYear } = parseIsoDate(end);
29
+ const chunks = [];
30
+ let currentYear = startYear;
31
+ while (currentYear <= endYear) {
32
+ const currentYearStart = formatIsoDate(currentYear, 1, 1);
33
+ const chunkStart = currentYearStart > start ? currentYearStart : start;
34
+ const nextYearFirst = formatIsoDate(currentYear + 1, 1, 1);
35
+ chunks.push([chunkStart, nextYearFirst]);
36
+ currentYear += 1;
37
+ }
38
+ return chunks;
39
+ }
40
+
41
+ // ../weather/src/_fetchers/iem-asos.ts
42
+ var IEM_BASE_URL = "https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py";
43
+ var IEM_POLITE_DELAY_MS = 1e3;
44
+ var STATION_CODE_RE = /^[A-Z]{3,4}$/;
45
+ var VALID_REPORT_TYPES = /* @__PURE__ */ new Set([3, 4]);
46
+ function sleep(ms) {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+ function validateIcao(stationCode) {
50
+ if (typeof stationCode !== "string" || !STATION_CODE_RE.test(stationCode)) {
51
+ throw new Error(
52
+ `station_code=${JSON.stringify(
53
+ stationCode
54
+ )} does not match STATION_CODE_RE (3-4 uppercase letters); refusing to use as URL component`
55
+ );
56
+ }
57
+ }
58
+ function buildIemUrl(stationCode, start, end, reportType) {
59
+ const [sy, sm, sd] = splitIso(start);
60
+ const [ey, em, ed] = splitIso(end);
61
+ 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}`;
62
+ return `${IEM_BASE_URL}?${params}`;
63
+ }
64
+ function splitIso(iso) {
65
+ const parts = iso.split("-");
66
+ if (parts.length !== 3) {
67
+ throw new Error(`Invalid ISO date passed to buildIemUrl: ${JSON.stringify(iso)}`);
68
+ }
69
+ return [
70
+ Number.parseInt(parts[0], 10),
71
+ Number.parseInt(parts[1], 10),
72
+ Number.parseInt(parts[2], 10)
73
+ ];
74
+ }
75
+ async function downloadIemAsos(stationCode, start, end, opts = {}) {
76
+ const reportType = opts.reportType ?? 3;
77
+ if (!VALID_REPORT_TYPES.has(reportType)) {
78
+ throw new Error(`report_type must be 3 (METAR) or 4 (SPECI), got ${reportType}`);
79
+ }
80
+ validateIcao(stationCode);
81
+ if (start > end) {
82
+ return [];
83
+ }
84
+ const normalizedStart = `${start.slice(0, 4)}-01-01`;
85
+ const chunks = yearlyChunksExclusiveEnd(normalizedStart, end);
86
+ const politenessMs = opts.politenessMs ?? IEM_POLITE_DELAY_MS;
87
+ const { reportType: _rtDrop, politenessMs: _pmDrop, ...fetchOpts } = opts;
88
+ const out = [];
89
+ for (const [chunkStart, chunkEnd] of chunks) {
90
+ const url = buildIemUrl(stationCode, chunkStart, chunkEnd, reportType);
91
+ const response = await fetchWithRetry(url, fetchOpts);
92
+ const csv = await response.text();
93
+ out.push({ chunkStart, chunkEnd, csv });
94
+ if (politenessMs > 0) {
95
+ await sleep(politenessMs);
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ export {
102
+ yearlyChunksExclusiveEnd,
103
+ IEM_BASE_URL,
104
+ IEM_POLITE_DELAY_MS,
105
+ buildIemUrl,
106
+ downloadIemAsos
107
+ };
108
+ //# sourceMappingURL=chunk-UKIFUUDX.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\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 * 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 // Tradewinds-specific normalization: clamp caller's `start` to Jan 1 of\n // its year so per-month callers share a yearly cache key (parity-faithful\n // with Python's `normalized_start = date(start.year, 1, 1)`). Mirrored in\n // TS so the URL shape (and the future TS-W3 cache key) stays byte-stable\n // with Python on the same range.\n const normalizedStart: IsoDate = `${start.slice(0, 4)}-01-01`;\n const chunks = yearlyChunksExclusiveEnd(normalizedStart, 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, ...fetchOpts } = opts;\n void _rtDrop;\n void _pmDrop;\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":[]}
@@ -0,0 +1,82 @@
1
+ // ../core/dist/internal/bounds.mjs
2
+ var SLP_MIN_MB = 870;
3
+ var SLP_MAX_MB = 1084;
4
+ var TEMP_MIN_C = -90;
5
+ var TEMP_MAX_C = 60;
6
+ var MAX_RAW_METAR_LEN = 2048;
7
+ var MAX_WX_CODES_LEN = 256;
8
+ var MAX_VISIBILITY_MILES = 99.99;
9
+ var WIND_DIR_BOUNDS = [0, 360];
10
+ var WIND_SPEED_MAX = 200;
11
+ var WIND_GUST_MAX = 250;
12
+ var SKY_BASE_MAX_FT = 6e4;
13
+ var MIN_YEAR = 1940;
14
+ var MAX_YEAR = 2100;
15
+ var STATION_CODE_RE = /^[A-Z]{3,4}$/;
16
+ var GHCNH_STATION_ID_RE = /^[A-Z0-9][A-Z0-9-]{0,31}$/;
17
+ function boundedInt(val, lo, hi) {
18
+ if (val === null || val === void 0) return null;
19
+ if (!Number.isFinite(val)) return null;
20
+ return val >= lo && val <= hi ? val : null;
21
+ }
22
+ function boundedFloat(val, lo, hi, _opts = {}) {
23
+ if (val === null || val === void 0) return null;
24
+ if (!Number.isFinite(val)) return null;
25
+ if (val >= lo && val <= hi) return val;
26
+ return null;
27
+ }
28
+ function boundedFloatMin(val, lo) {
29
+ if (val === null || val === void 0) return null;
30
+ if (!Number.isFinite(val)) return null;
31
+ return val >= lo ? val : null;
32
+ }
33
+ function validateIcaoForPath(value, field = "station") {
34
+ if (typeof value !== "string") {
35
+ throw new Error(
36
+ `${field} must be a string (got ${typeof value}); unsafe to use in URL or cache path`
37
+ );
38
+ }
39
+ if (!STATION_CODE_RE.test(value)) {
40
+ throw new Error(
41
+ `${field}=${JSON.stringify(value)} does not match STATION_CODE_RE (3-4 uppercase letters); refusing to use as URL or path component`
42
+ );
43
+ }
44
+ return value;
45
+ }
46
+ function validateGhcnhIdForPath(value, field = "station_id") {
47
+ if (typeof value !== "string") {
48
+ throw new Error(
49
+ `${field} must be a string (got ${typeof value}); unsafe to use in URL or cache path`
50
+ );
51
+ }
52
+ if (!GHCNH_STATION_ID_RE.test(value)) {
53
+ throw new Error(
54
+ `${field}=${JSON.stringify(value)} does not match GHCNH_STATION_ID_RE (alphanumeric + hyphen, 1-32 chars); refusing to use as URL or path component`
55
+ );
56
+ }
57
+ return value;
58
+ }
59
+
60
+ export {
61
+ SLP_MIN_MB,
62
+ SLP_MAX_MB,
63
+ TEMP_MIN_C,
64
+ TEMP_MAX_C,
65
+ MAX_RAW_METAR_LEN,
66
+ MAX_WX_CODES_LEN,
67
+ MAX_VISIBILITY_MILES,
68
+ WIND_DIR_BOUNDS,
69
+ WIND_SPEED_MAX,
70
+ WIND_GUST_MAX,
71
+ SKY_BASE_MAX_FT,
72
+ MIN_YEAR,
73
+ MAX_YEAR,
74
+ STATION_CODE_RE,
75
+ GHCNH_STATION_ID_RE,
76
+ boundedInt,
77
+ boundedFloat,
78
+ boundedFloatMin,
79
+ validateIcaoForPath,
80
+ validateGhcnhIdForPath
81
+ };
82
+ //# sourceMappingURL=chunk-VESWR46G.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../core/src/internal/bounds.ts"],"sourcesContent":["// Schema-derived bounds and validation helpers.\n//\n// Ported from `packages/core/src/mostlyright/_internal/_bounds.py`.\n// Constants from specs/observation.json. Shared by AWC, GHCNh, and IEM parsers.\n\n// Pressure bounds (observation.json: sea_level_pressure_mb)\nexport const SLP_MIN_MB = 870.0;\nexport const SLP_MAX_MB = 1084.0;\n\n// Temperature bounds (°C). World records: -89.2°C (Vostok) / 56.7°C (Death Valley).\nexport const TEMP_MIN_C = -90.0;\nexport const TEMP_MAX_C = 60.0;\n\n// String length limits\nexport const MAX_RAW_METAR_LEN = 2048;\nexport const MAX_WX_CODES_LEN = 256;\n\n// Visibility (observation.json: visibility_miles max)\nexport const MAX_VISIBILITY_MILES = 99.99;\n\n// Wind bounds (observation.json: wind_dir_degrees, wind_speed_kt, wind_gust_kt)\nexport const WIND_DIR_BOUNDS: readonly [number, number] = [0, 360];\nexport const WIND_SPEED_MAX = 200;\nexport const WIND_GUST_MAX = 250;\n\n// Sky (observation.json: sky_base max)\nexport const SKY_BASE_MAX_FT = 60000;\n\n// Year range for timestamp validation\nexport const MIN_YEAR = 1940;\nexport const MAX_YEAR = 2100;\n\n// Station code regex — security boundary: codes flow into URL params and\n// cache paths. Use ^...$ in TS (no multiline flag) so the absolute string\n// ends are matched (equivalent to Python's `\\A...\\Z` here since `re.match`\n// is anchored at start; we want anchored at both ends and no embedded\n// newlines).\nexport const STATION_CODE_RE = /^[A-Z]{3,4}$/;\n\n// GHCNh station identifier regex. NCEI uses two id flavours:\n// - ICAO-derived joined USAF-WBAN form, e.g. \"744860-94789\"\n// - 11-character NCEI station ids, alphanumeric\n// Either way: alphanumeric + hyphen, length-bounded, anchored.\nexport const GHCNH_STATION_ID_RE = /^[A-Z0-9][A-Z0-9-]{0,31}$/;\n\n// ---------------------------------------------------------------------------\n// Bounded numerics\n// ---------------------------------------------------------------------------\n\nexport function boundedInt(val: number | null, lo: number, hi: number): number | null {\n if (val === null || val === undefined) return null;\n if (!Number.isFinite(val)) return null;\n return val >= lo && val <= hi ? val : null;\n}\n\nexport interface BoundedFloatOptions {\n /** Field name used for diagnostic logging (optional). */\n field?: string;\n}\n\nexport function boundedFloat(\n val: number | null,\n lo: number,\n hi: number,\n _opts: BoundedFloatOptions = {},\n): number | null {\n if (val === null || val === undefined) return null;\n if (!Number.isFinite(val)) return null;\n if (val >= lo && val <= hi) return val;\n return null;\n}\n\nexport function boundedFloatMin(val: number | null, lo: number): number | null {\n if (val === null || val === undefined) return null;\n if (!Number.isFinite(val)) return null;\n return val >= lo ? val : null;\n}\n\n// ---------------------------------------------------------------------------\n// Path-boundary validators\n// ---------------------------------------------------------------------------\n\n/**\n * Validate that `value` is a 3-4 letter uppercase ICAO/IATA code safe for\n * use as a URL parameter or filesystem path component. Throws `Error` on\n * mismatch — codes flow into URL params and cache paths, so any\n * path-separator character, whitespace, or non-ASCII char is rejected.\n */\nexport function validateIcaoForPath(value: unknown, field = \"station\"): string {\n if (typeof value !== \"string\") {\n throw new Error(\n `${field} must be a string (got ${typeof value}); unsafe to use in URL or cache path`,\n );\n }\n if (!STATION_CODE_RE.test(value)) {\n throw new Error(\n `${field}=${JSON.stringify(value)} does not match STATION_CODE_RE (3-4 uppercase letters); refusing to use as URL or path component`,\n );\n }\n return value;\n}\n\n/**\n * Validate that `value` is a GHCNh station identifier safe for URL/path use.\n * Accepts alphanumeric + hyphen, 1-32 chars, first char alphanumeric.\n */\nexport function validateGhcnhIdForPath(value: unknown, field = \"station_id\"): string {\n if (typeof value !== \"string\") {\n throw new Error(\n `${field} must be a string (got ${typeof value}); unsafe to use in URL or cache path`,\n );\n }\n if (!GHCNH_STATION_ID_RE.test(value)) {\n throw new Error(\n `${field}=${JSON.stringify(value)} does not match GHCNH_STATION_ID_RE (alphanumeric + hyphen, 1-32 chars); refusing to use as URL or path component`,\n );\n }\n return value;\n}\n"],"mappings":";AAMO,IAAM,aAAa;AACnB,IAAM,aAAa;AAGnB,IAAM,aAAa;AACnB,IAAM,aAAa;AAGnB,IAAM,oBAAoB;AAC1B,IAAM,mBAAmB;AAGzB,IAAM,uBAAuB;AAG7B,IAAM,kBAA6C,CAAC,GAAG,GAAG;AAC1D,IAAM,iBAAiB;AACvB,IAAM,gBAAgB;AAGtB,IAAM,kBAAkB;AAGxB,IAAM,WAAW;AACjB,IAAM,WAAW;AAOjB,IAAM,kBAAkB;AAMxB,IAAM,sBAAsB;AAM5B,SAAS,WAAW,KAAoB,IAAY,IAA2B;AACpF,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,SAAO,OAAO,MAAM,OAAO,KAAK,MAAM;AACxC;AAOO,SAAS,aACd,KACA,IACA,IACA,QAA6B,CAAC,GACf;AACf,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,MAAI,OAAO,MAAM,OAAO,GAAI,QAAO;AACnC,SAAO;AACT;AAEO,SAAS,gBAAgB,KAAoB,IAA2B;AAC7E,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,SAAO,OAAO,KAAK,MAAM;AAC3B;AAYO,SAAS,oBAAoB,OAAgB,QAAQ,WAAmB;AAC7E,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,IAAI;MACR,GAAG,KAAK,0BAA0B,OAAO,KAAK;IAChD;EACF;AACA,MAAI,CAAC,gBAAgB,KAAK,KAAK,GAAG;AAChC,UAAM,IAAI;MACR,GAAG,KAAK,IAAI,KAAK,UAAU,KAAK,CAAC;IACnC;EACF;AACA,SAAO;AACT;AAMO,SAAS,uBAAuB,OAAgB,QAAQ,cAAsB;AACnF,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,IAAI;MACR,GAAG,KAAK,0BAA0B,OAAO,KAAK;IAChD;EACF;AACA,MAAI,CAAC,oBAAoB,KAAK,KAAK,GAAG;AACpC,UAAM,IAAI;MACR,GAAG,KAAK,IAAI,KAAK,UAAU,KAAK,CAAC;IACnC;EACF;AACA,SAAO;AACT;","names":[]}