mostlyright 1.0.0 → 1.1.0
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-6ERO2BIY.mjs → chunk-TREZXL75.mjs} +32 -21
- package/dist/chunk-TREZXL75.mjs.map +1 -0
- package/dist/{iem-5RVPI3TY.mjs → iem-IO2HIL5V.mjs} +2 -2
- package/dist/index.bundle.mjs +7 -19
- package/dist/index.bundle.mjs.map +1 -1
- package/dist/index.global.js +45 -38
- package/dist/index.global.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-6ERO2BIY.mjs.map +0 -1
- /package/dist/{iem-5RVPI3TY.mjs.map → iem-IO2HIL5V.mjs.map} +0 -0
|
@@ -38,6 +38,21 @@ function hpaToInhg(hpa) {
|
|
|
38
38
|
return v === null ? null : v * HPA_TO_INHG;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// ../weather/src/_internal/tgroup.ts
|
|
42
|
+
var TGROUP_RE = /\bT([01])(\d{3})([01])(\d{3})\b/;
|
|
43
|
+
function parseTgroup(rawMetar) {
|
|
44
|
+
if (!rawMetar) return [null, null];
|
|
45
|
+
const rmkIdx = rawMetar.indexOf("RMK");
|
|
46
|
+
if (rmkIdx < 0) return [null, null];
|
|
47
|
+
const match = TGROUP_RE.exec(rawMetar.slice(rmkIdx));
|
|
48
|
+
if (!match) return [null, null];
|
|
49
|
+
const tSign = match[1] === "1" ? -1 : 1;
|
|
50
|
+
const tVal = Number.parseInt(match[2], 10) / 10 * tSign;
|
|
51
|
+
const dSign = match[3] === "1" ? -1 : 1;
|
|
52
|
+
const dVal = Number.parseInt(match[4], 10) / 10 * dSign;
|
|
53
|
+
return [tVal, dVal];
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
// ../weather/src/_parsers/awc.ts
|
|
42
57
|
var [WIND_DIR_LO, WIND_DIR_HI] = WIND_DIR_BOUNDS;
|
|
43
58
|
function icaoToStationCode(icao) {
|
|
@@ -101,7 +116,6 @@ function parseAwcVisibility(vis) {
|
|
|
101
116
|
return Math.min(n, MAX_VISIBILITY_MILES);
|
|
102
117
|
}
|
|
103
118
|
var PK_WND_RE = /PK WND (\d{3})(\d{2,3})\/(\d{4})/;
|
|
104
|
-
var TGROUP_RE = /\bT([01])(\d{3})([01])(\d{3})\b/;
|
|
105
119
|
function parsePeakWind(rawMetar) {
|
|
106
120
|
if (!rawMetar) return { dir: null, speed: null, time: null };
|
|
107
121
|
const m = PK_WND_RE.exec(rawMetar);
|
|
@@ -114,18 +128,6 @@ function parsePeakWind(rawMetar) {
|
|
|
114
128
|
}
|
|
115
129
|
return { dir, speed: spd, time };
|
|
116
130
|
}
|
|
117
|
-
function parseTGroup(rawMetar) {
|
|
118
|
-
if (!rawMetar) return { tempC: null, dewpC: null };
|
|
119
|
-
const rmkIdx = rawMetar.indexOf("RMK");
|
|
120
|
-
if (rmkIdx < 0) return { tempC: null, dewpC: null };
|
|
121
|
-
const m = TGROUP_RE.exec(rawMetar.slice(rmkIdx));
|
|
122
|
-
if (!m) return { tempC: null, dewpC: null };
|
|
123
|
-
const tSign = m[1] === "1" ? -1 : 1;
|
|
124
|
-
const tVal = Number.parseInt(m[2], 10) / 10 * tSign;
|
|
125
|
-
const dSign = m[3] === "1" ? -1 : 1;
|
|
126
|
-
const dVal = Number.parseInt(m[4], 10) / 10 * dSign;
|
|
127
|
-
return { tempC: tVal, dewpC: dVal };
|
|
128
|
-
}
|
|
129
131
|
function safeInt(v) {
|
|
130
132
|
if (v === null || v === void 0) return null;
|
|
131
133
|
const f = typeof v === "number" ? v : Number(v);
|
|
@@ -198,13 +200,15 @@ function awcToObservation(m) {
|
|
|
198
200
|
}
|
|
199
201
|
let tempC = safeFloat(m.temp);
|
|
200
202
|
let dewpC = safeFloat(m.dewp);
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
203
|
+
const [tgTemp, tgDewp] = parseTgroup(rawMetar);
|
|
204
|
+
const hasTgroupTemp = tgTemp !== null;
|
|
205
|
+
const hasTgroupDewp = tgDewp !== null;
|
|
206
|
+
if (hasTgroupTemp) tempC = tgTemp;
|
|
207
|
+
if (hasTgroupDewp) dewpC = tgDewp;
|
|
204
208
|
tempC = boundedFloat(tempC, TEMP_MIN_C, TEMP_MAX_C);
|
|
205
209
|
dewpC = boundedFloat(dewpC, TEMP_MIN_C, TEMP_MAX_C);
|
|
206
|
-
const tempF = celsiusToFahrenheit(tempC);
|
|
207
|
-
const dewpointF = celsiusToFahrenheit(dewpC);
|
|
210
|
+
const tempF = hasTgroupTemp && tempC !== null ? Math.round(tempC * 9 / 5 + 32) : celsiusToFahrenheit(tempC);
|
|
211
|
+
const dewpointF = hasTgroupDewp && dewpC !== null ? Math.round(dewpC * 9 / 5 + 32) : celsiusToFahrenheit(dewpC);
|
|
208
212
|
const pk = parsePeakWind(rawMetar);
|
|
209
213
|
const pkDir = boundedInt(pk.dir, WIND_DIR_LO, WIND_DIR_HI);
|
|
210
214
|
const pkSpd = boundedInt(pk.speed, 0, WIND_GUST_MAX);
|
|
@@ -325,12 +329,19 @@ function iemToObservation(row, opts = {}) {
|
|
|
325
329
|
const observationType = override !== void 0 ? override : detectObsType(metarText);
|
|
326
330
|
const rawTempF = safeFloat2(row.tmpf ?? "");
|
|
327
331
|
const rawDewpF = safeFloat2(row.dwpf ?? "");
|
|
328
|
-
|
|
332
|
+
let tempC = boundedFloat(fahrenheitToCelsius(rawTempF), TEMP_MIN_C, TEMP_MAX_C, {
|
|
329
333
|
field: "temp_c"
|
|
330
334
|
});
|
|
331
|
-
|
|
335
|
+
let dewpC = boundedFloat(fahrenheitToCelsius(rawDewpF), TEMP_MIN_C, TEMP_MAX_C, {
|
|
332
336
|
field: "dewpoint_c"
|
|
333
337
|
});
|
|
338
|
+
const [tgTemp, tgDewp] = parseTgroup(metarText);
|
|
339
|
+
if (tgTemp !== null && tempC !== null) {
|
|
340
|
+
tempC = boundedFloat(tgTemp, TEMP_MIN_C, TEMP_MAX_C, { field: "temp_c" });
|
|
341
|
+
}
|
|
342
|
+
if (tgDewp !== null && dewpC !== null) {
|
|
343
|
+
dewpC = boundedFloat(tgDewp, TEMP_MIN_C, TEMP_MAX_C, { field: "dewpoint_c" });
|
|
344
|
+
}
|
|
334
345
|
const tempF = tempC !== null ? rawTempF : null;
|
|
335
346
|
const dewpF = dewpC !== null ? rawDewpF : null;
|
|
336
347
|
const windDir = boundedInt(safeInt2(row.drct ?? ""), WIND_DIR_LO2, WIND_DIR_HI2);
|
|
@@ -433,4 +444,4 @@ export {
|
|
|
433
444
|
iemToObservation,
|
|
434
445
|
parseIemCsv
|
|
435
446
|
};
|
|
436
|
-
//# sourceMappingURL=chunk-
|
|
447
|
+
//# sourceMappingURL=chunk-TREZXL75.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../core/src/internal/convert.ts","../../weather/src/_internal/tgroup.ts","../../weather/src/_parsers/awc.ts","../../weather/src/_parsers/iem.ts"],"sourcesContent":["// Unit conversions for the mostlyright SDK.\n//\n// Ported from `packages/core/src/mostlyright/_internal/_convert.py`.\n//\n// CRITICAL: No rounding anywhere. Store float64 as-is. The Python module's\n// \"no _go_round / no round / no math.floor(x + 0.5)\" rule applies here too —\n// every formula returns the raw float64 result.\n\n// ---------------------------------------------------------------------------\n// Constants (exact-by-definition where possible)\n// ---------------------------------------------------------------------------\n\nexport const KT_TO_MPH = 1.15078;\n/** Exact: 1 knot = 1852 m / 3600 s. */\nexport const KT_TO_MS = 1852.0 / 3600.0;\n/** Exact by definition (mile → kilometre). */\nexport const MI_TO_KM = 1.609344;\n/** Exact by definition (mile → metre). */\nexport const MI_TO_M = 1609.344;\n/** Exact by definition (foot → metre). */\nexport const FT_TO_M = 0.3048;\n/** Exact by definition (inch → millimetre). */\nexport const IN_TO_MM = 25.4;\n/** WMO standard conversion factor (hectopascal → inHg). */\nexport const HPA_TO_INHG = 0.0295299875;\n\n// August–Roche–Magnus approximation (Alduchov & Eskridge 1996)\nexport const MAGNUS_A = 17.625;\n/** Celsius. */\nexport const MAGNUS_B = 243.04;\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction finiteOrNull(v: number | null | undefined): number | null {\n if (v === null || v === undefined) return null;\n return Number.isFinite(v) ? v : null;\n}\n\n// ---------------------------------------------------------------------------\n// Public conversions\n// ---------------------------------------------------------------------------\n\nexport function ktToMs(kt: number | null): number | null {\n const v = finiteOrNull(kt);\n return v === null ? null : v * KT_TO_MS;\n}\n\nexport function ktToMph(kt: number | null): number | null {\n const v = finiteOrNull(kt);\n return v === null ? null : v * KT_TO_MPH;\n}\n\nexport function miToKm(mi: number | null): number | null {\n const v = finiteOrNull(mi);\n return v === null ? null : v * MI_TO_KM;\n}\n\nexport function miToM(mi: number | null): number | null {\n const v = finiteOrNull(mi);\n return v === null ? null : v * MI_TO_M;\n}\n\nexport function ftToM(ft: number | null): number | null {\n const v = finiteOrNull(ft);\n return v === null ? null : v * FT_TO_M;\n}\n\nexport function inchesToMm(inches: number | null): number | null {\n const v = finiteOrNull(inches);\n return v === null ? null : v * IN_TO_MM;\n}\n\nexport function celsiusToFahrenheit(c: number | null): number | null {\n const v = finiteOrNull(c);\n return v === null ? null : (v * 9) / 5 + 32;\n}\n\nexport function fahrenheitToCelsius(f: number | null): number | null {\n const v = finiteOrNull(f);\n return v === null ? null : ((v - 32) * 5) / 9;\n}\n\nexport function hpaToInhg(hpa: number | null): number | null {\n const v = finiteOrNull(hpa);\n return v === null ? null : v * HPA_TO_INHG;\n}\n\n/**\n * Compute relative humidity (%) from temperature and dewpoint (both Celsius)\n * via the August–Roche–Magnus approximation. Result is clamped to [0, 100];\n * non-finite or null inputs return null.\n */\nexport function computeRelativeHumidity(tempC: number | null, dewpC: number | null): number | null {\n const t = finiteOrNull(tempC);\n const td = finiteOrNull(dewpC);\n if (t === null || td === null) return null;\n // exp can overflow at extreme inputs; guard for safety.\n const numer = Math.exp((MAGNUS_A * td) / (MAGNUS_B + td));\n const denom = Math.exp((MAGNUS_A * t) / (MAGNUS_B + t));\n if (!Number.isFinite(numer) || !Number.isFinite(denom) || denom === 0) {\n return null;\n }\n const rh = (100.0 * numer) / denom;\n return Math.max(0.0, Math.min(rh, 100.0));\n}\n\n/**\n * Compute feels-like temperature in Fahrenheit (full NWS algorithm).\n *\n * - Wind chill at or below 50°F with wind > 3 mph.\n * - Heat index at or above 80°F with known RH.\n * - Plain temp otherwise. No rounding.\n *\n * `windKt` is in knots (matches Python signature); null is treated as zero.\n * `rh` is relative humidity in [0, 100]; null disables the heat-index branch.\n */\nexport function computeFeelsLike(\n tempF: number | null,\n windKt: number | null,\n rh: number | null,\n): number | null {\n const t = finiteOrNull(tempF);\n if (t === null) return null;\n\n let wMph = 0.0;\n if (windKt !== null && windKt !== undefined) {\n const w = Number.isFinite(windKt) ? windKt * KT_TO_MPH : null;\n if (w === null || !Number.isFinite(w)) return null;\n wMph = w;\n }\n\n // Treat non-finite rh as missing (don't feed NaN into heat index).\n let rhSafe: number | null = rh;\n if (rhSafe !== null && rhSafe !== undefined && !Number.isFinite(rhSafe)) {\n rhSafe = null;\n }\n\n // Wind chill (NWS): valid for temp <= 50°F and wind > 3 mph\n if (t <= 50.0 && wMph > 3.0) {\n return 35.74 + 0.6215 * t - 35.75 * wMph ** 0.16 + 0.4275 * t * wMph ** 0.16;\n }\n\n // Heat index (NWS): requires known RH\n if (t >= 80.0 && rhSafe !== null) {\n const h = rhSafe;\n // Step 1: Steadman simplified\n const simple = 0.5 * (t + 61.0 + (t - 68.0) * 1.2 + h * 0.094);\n if ((simple + t) / 2.0 < 80.0) {\n return simple;\n }\n\n // Step 2: Rothfusz regression\n let hi =\n -42.379 +\n 2.04901523 * t +\n 10.14333127 * h -\n 0.22475541 * t * h -\n 0.00683783 * t * t -\n 0.05481717 * h * h +\n 0.00122874 * t * t * h +\n 0.00085282 * t * h * h -\n 0.00000199 * t * t * h * h;\n\n // Step 3: NWS adjustments\n if (h < 13.0 && t >= 80.0 && t <= 112.0) {\n hi -= ((13.0 - h) / 4.0) * Math.sqrt((17.0 - Math.abs(t - 95.0)) / 17.0);\n } else if (h > 85.0 && t >= 80.0 && t <= 87.0) {\n hi += ((h - 85.0) / 10.0) * ((87.0 - t) / 5.0);\n }\n\n return hi;\n }\n\n return t;\n}\n","// Phase 18 PREC-02: shared Tgroup parser for ASOS METAR remarks.\n//\n// TS parity port of packages/weather/src/mostlyright/weather/_internal/tgroup.py.\n// Extracted so AWC and IEM (and any future consumer) parse Tgroup identically.\n// The Tgroup is the canonical tenths-°C encoding of the integer-°F ASOS\n// reading; recovering it from raw METAR remarks is the single source of truth\n// for U.S. ASOS temperature precision. See\n// .planning/phases/18-precision-fix-asos-integer-fahrenheit/18-CONTEXT.md.\n\n/**\n * Regex matching the T-group in METAR remarks: T{s}{SSS}{s}{DDD}.\n *\n * - s=0 positive, s=1 negative.\n * - SSS/DDD = tenths of °C.\n *\n * Examples: `T02560167` → 25.6°C / 16.7°C. `T10390061` → -3.9°C / 6.1°C.\n */\nexport const TGROUP_RE = /\\bT([01])(\\d{3})([01])(\\d{3})\\b/;\n\n/**\n * Parse T-group from METAR remarks for tenths-precision temperature.\n *\n * ASOS stations always include T-group in remarks. Format:\n * `T{s}{SSS}{s}{DDD}` where s=0 positive, s=1 negative, SSS=temp tenths °C,\n * DDD=dewpoint tenths °C. Searches only the remarks section (after `RMK`)\n * to avoid false positives on body group patterns.\n *\n * Returns `[temp_c, dewpoint_c]` as a 2-tuple, mirroring the Python helper\n * shape. Either element may be `null`:\n * - `[null, null]` when input is empty/null, has no `RMK` section, or no\n * Tgroup match.\n *\n * @example\n * parseTgroup(\"KLGA 281451Z 27008KT 10SM CLR 27/06 A3001 RMK T02670061\")\n * // → [26.7, 6.1]\n */\nexport function parseTgroup(rawMetar: string | null | undefined): [number | null, number | null] {\n if (!rawMetar) return [null, null];\n // T-group is a remarks-only element — search only after RMK.\n // No RMK section = no T-group. Do NOT fallback to full string to avoid\n // false positives on body group patterns.\n const rmkIdx = rawMetar.indexOf(\"RMK\");\n if (rmkIdx < 0) return [null, null];\n const match = TGROUP_RE.exec(rawMetar.slice(rmkIdx));\n if (!match) return [null, null];\n const tSign = match[1] === \"1\" ? -1 : 1;\n const tVal = (Number.parseInt(match[2] as string, 10) / 10.0) * tSign;\n const dSign = match[3] === \"1\" ? -1 : 1;\n const dVal = (Number.parseInt(match[4] as string, 10) / 10.0) * dSign;\n return [tVal, dVal];\n}\n","// AWC METAR parser — maps raw AWC JSON record to `Observation` row.\n//\n// Ported byte-faithfully from\n// `packages/weather/src/mostlyright/weather/_awc.py::awc_to_observation`.\n//\n// Output `source` is `\"awc\"` — matching the row-level enum\n// (`source: \"awc\" | \"iem\" | \"ghcnh\"` per\n// `packages-ts/core/src/schemas/generated/observation_qc.v1.ts`) and the\n// Python parser (`packages/weather/src/mostlyright/weather/_awc.py:320`).\n// The `.live` / `.archive` suffix is a CATALOG / orchestrator source-id\n// (a higher layer concern), NOT a row-level field.\n//\n// Bounds + conversion constants are imported from `@mostlyrightmd/core/internal/{bounds,convert}`\n// (subpath exports added in TS-W1 iter-1 HIGH 4). Any change to the\n// constants in core propagates here automatically — no drift.\n\nimport {\n MAX_RAW_METAR_LEN,\n MAX_VISIBILITY_MILES,\n MAX_WX_CODES_LEN,\n SKY_BASE_MAX_FT,\n SLP_MAX_MB,\n SLP_MIN_MB,\n STATION_CODE_RE,\n TEMP_MAX_C,\n TEMP_MIN_C,\n WIND_DIR_BOUNDS,\n WIND_GUST_MAX,\n WIND_SPEED_MAX,\n boundedFloat,\n boundedFloatMin,\n boundedInt,\n} from \"@mostlyrightmd/core/internal/bounds\";\nimport { celsiusToFahrenheit, hpaToInhg } from \"@mostlyrightmd/core/internal/convert\";\n\nimport type { AwcMetarRaw } from \"../_fetchers/awc.js\";\nimport { parseTgroup } from \"../_internal/tgroup.js\";\n\nconst [WIND_DIR_LO, WIND_DIR_HI] = WIND_DIR_BOUNDS;\n\n// ---------------------------------------------------------------------------\n// Observation row schema (matches specs/observation.json field set)\n// ---------------------------------------------------------------------------\n\n/**\n * Single observation row, matching `specs/observation.json`.\n *\n * Notes on null vs unknown:\n * - Every field defaults to `null` if the upstream record omits it,\n * fails bounds, or fails type parsing. This mirrors the Python\n * parser (no exceptions on bad input — only on missing required keys).\n * - `source` is always the string literal `\"awc\"` for AWC-sourced rows\n * (the row-level enum is `\"awc\" | \"iem\" | \"ghcnh\"`; the `.live`/`.archive`\n * suffix is a CATALOG-layer source-id, not a row field).\n * - `observed_at` is ISO 8601 UTC with `Z` suffix.\n */\nexport interface Observation {\n readonly station_code: string;\n readonly observed_at: string;\n readonly observation_type: \"METAR\" | \"SPECI\";\n /**\n * Per-row source tag. Widened in TS-W2 Plan 01 to cover all 3 row-level\n * observation sources (AWC live, IEM ASOS archive, GHCNh archive). Each\n * parser still emits its own literal — AWC emits `\"awc\"`, IEM ASOS emits\n * `\"iem\"`, GHCNh emits `\"ghcnh\"` — but the shared `Observation` contract\n * accepts all three so `mergeObservations` (TS-W2 Plan 04) sees one row\n * shape across sources. Matches Python `schema.observation.v1.source` enum.\n */\n readonly source: \"awc\" | \"iem\" | \"ghcnh\";\n readonly temp_c: number | null;\n readonly dewpoint_c: number | null;\n readonly temp_f: number | null;\n readonly dewpoint_f: number | null;\n readonly wind_dir_degrees: number | null;\n readonly wind_speed_kt: number | null;\n readonly wind_gust_kt: number | null;\n readonly altimeter_inhg: number | null;\n readonly sea_level_pressure_mb: number | null;\n readonly sky_cover_1: string | null;\n readonly sky_base_1_ft: number | null;\n readonly sky_cover_2: string | null;\n readonly sky_base_2_ft: number | null;\n readonly sky_cover_3: string | null;\n readonly sky_base_3_ft: number | null;\n readonly sky_cover_4: string | null;\n readonly sky_base_4_ft: number | null;\n readonly visibility_miles: number | null;\n readonly weather_codes: string | null;\n readonly precip_1hr_inches: number | null;\n readonly peak_wind_gust_kt: number | null;\n readonly peak_wind_dir: number | null;\n readonly peak_wind_time: string | null;\n readonly snow_depth_inches: number | null;\n readonly qc_field: number | null;\n readonly raw_metar: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Public helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Strip the leading `K` from 4-letter CONUS ICAO codes to get the\n * 3-letter NWS station code (`KNYC` → `NYC`). Non-K-prefixed codes\n * pass through unchanged after `.toUpperCase()`.\n */\nexport function icaoToStationCode(icao: string): string {\n const upper = icao.trim().toUpperCase();\n if (upper.startsWith(\"K\") && upper.length === 4) {\n return upper.slice(1);\n }\n return upper;\n}\n\n/**\n * Map AWC cloud-cover token to the standard abbreviation set.\n * - Accepts {CLR, SKC, FEW, SCT, BKN, OVC, VV}\n * - `CAVOK` → `CLR`\n * - everything else (incl. null/undefined) → null\n */\nexport function mapCloudCover(cover: string | null | undefined): string | null {\n if (cover === null || cover === undefined) return null;\n const upper = String(cover).toUpperCase();\n if (\n upper === \"CLR\" ||\n upper === \"SKC\" ||\n upper === \"FEW\" ||\n upper === \"SCT\" ||\n upper === \"BKN\" ||\n upper === \"OVC\" ||\n upper === \"VV\"\n ) {\n return upper;\n }\n if (upper === \"CAVOK\") {\n return \"CLR\";\n }\n return null;\n}\n\n/**\n * Parse AWC visibility — handles all forms documented in the Python parser:\n * - numeric (`10`) → `10`\n * - \"10+\" → `10` (trailing plus = \"or more\")\n * - \"1/2\" → `0.5`\n * - \"2 1/4\" → `2.25`\n * - \"M1/4\" → `0.25` (METAR \"less than\" prefix)\n * - bad input / empty / \"null\" → `null`\n *\n * All results are clamped at `MAX_VISIBILITY_MILES` (99.99).\n */\nexport function parseAwcVisibility(vis: string | number | null | undefined): number | null {\n if (vis === null || vis === undefined) return null;\n\n // Numeric pass-through (must be finite).\n if (typeof vis === \"number\") {\n if (!Number.isFinite(vis)) return null;\n return Math.min(vis, MAX_VISIBILITY_MILES);\n }\n\n const s = String(vis);\n if (s === \"\" || s === \"null\") return null;\n\n // \"10+\" → 10\n if (s.endsWith(\"+\")) {\n const n = Number(s.slice(0, -1));\n if (!Number.isFinite(n)) return null;\n return Math.min(n, MAX_VISIBILITY_MILES);\n }\n\n // Mixed number \"2 1/4\" → 2.25 (space + slash both present)\n if (s.includes(\" \") && s.includes(\"/\")) {\n const parts = s.split(\" \");\n if (parts.length !== 2) return null;\n const whole = Number(parts[0]);\n const frac = (parts[1] as string).split(\"/\");\n if (frac.length !== 2) return null;\n const num = Number(frac[0]);\n const den = Number(frac[1]);\n if (!(Number.isFinite(whole) && Number.isFinite(num) && Number.isFinite(den) && den !== 0)) {\n return null;\n }\n return Math.min(whole + num / den, MAX_VISIBILITY_MILES);\n }\n\n // Simple fraction \"1/2\" or \"M1/4\"\n if (s.includes(\"/\")) {\n let trimmed = s;\n if (trimmed.startsWith(\"M\") || trimmed.startsWith(\"m\")) {\n trimmed = trimmed.slice(1);\n }\n const frac = trimmed.split(\"/\");\n if (frac.length !== 2) return null;\n const num = Number(frac[0]);\n const den = Number(frac[1]);\n if (!(Number.isFinite(num) && Number.isFinite(den) && den !== 0)) return null;\n return Math.min(num / den, MAX_VISIBILITY_MILES);\n }\n\n // Plain numeric string.\n const n = Number(s);\n if (!Number.isFinite(n)) return null;\n return Math.min(n, MAX_VISIBILITY_MILES);\n}\n\n// ---------------------------------------------------------------------------\n// METAR remarks parsers (peak wind + T-group)\n// ---------------------------------------------------------------------------\n\n// `PK WND dddss/hhmm` (direction may be 2-3 digits; AWC payloads use 3+2)\nconst PK_WND_RE = /PK WND (\\d{3})(\\d{2,3})\\/(\\d{4})/;\n\n// Phase 18 PREC-02: Tgroup regex + parser lifted to the shared\n// _internal/tgroup.ts module so AWC + IEM use one source of truth.\n\nfunction parsePeakWind(rawMetar: string | null): {\n dir: number | null;\n speed: number | null;\n time: string | null;\n} {\n if (!rawMetar) return { dir: null, speed: null, time: null };\n const m = PK_WND_RE.exec(rawMetar);\n if (!m) return { dir: null, speed: null, time: null };\n const dir = Number.parseInt(m[1] as string, 10);\n const spd = Number.parseInt(m[2] as string, 10);\n const time = m[3] as string;\n if (!(dir >= 0 && dir <= 360) || spd < 0) {\n return { dir: null, speed: null, time: null };\n }\n return { dir, speed: spd, time };\n}\n\n// ---------------------------------------------------------------------------\n// Safe numeric coercion (mirror Python `_safe_int`, `_safe_float`, `_safe_precip`)\n// ---------------------------------------------------------------------------\n\nfunction safeInt(v: unknown): number | null {\n if (v === null || v === undefined) return null;\n const f = typeof v === \"number\" ? v : Number(v);\n if (!Number.isFinite(f)) return null;\n // Python uses banker's `round()`; for non-negative half-values the\n // difference would matter, but observation payloads contain integral\n // counts (wind speed, sky base feet, qc bitmask). Use Math.round which\n // matches \"round half away from zero\" — adequate for the integer\n // domains we coerce here.\n return Math.round(f);\n}\n\nfunction safeFloat(v: unknown): number | null {\n if (v === null || v === undefined) return null;\n const f = typeof v === \"number\" ? v : Number(v);\n return Number.isFinite(f) ? f : null;\n}\n\nfunction safePrecip(v: unknown): number | null {\n if (v === null || v === undefined) return null;\n if (typeof v === \"string\" && v.trim().toUpperCase() === \"T\") {\n // Python returns 0.0 for trace; the task spec mentions \"0.005 (Python\n // trace convention)\" but the actual Python source maps \"T\" → 0.0 (see\n // `_safe_precip` in `_awc.py`). We match Python source byte-faithfully.\n return 0.0;\n }\n return safeFloat(v);\n}\n\nfunction cloudLayer(layer: unknown): { cover: string | null; base: number | null } {\n if (layer === null || typeof layer !== \"object\") return { cover: null, base: null };\n const obj = layer as { cover?: unknown; base?: unknown };\n const base = boundedInt(safeInt(obj.base), 0, SKY_BASE_MAX_FT);\n return { cover: mapCloudCover(obj.cover as string | null | undefined), base };\n}\n\n// ---------------------------------------------------------------------------\n// Main parser\n// ---------------------------------------------------------------------------\n\n/**\n * Convert one raw AWC METAR record into the canonical observation row.\n *\n * Returns `null` if the record is missing `icaoId` or `obsTime`, or if the\n * station code can't be resolved via `icaoToStationCode` + STATION_CODE_RE,\n * or if `obsTime` produces an out-of-range date.\n *\n * Otherwise returns a fully-typed `Observation` with every field either a\n * validated value or `null`. Never throws.\n */\nexport function awcToObservation(m: AwcMetarRaw): Observation | null {\n // --- Required keys -----------------------------------------------------\n const icaoId = m.icaoId;\n if (typeof icaoId !== \"string\" || icaoId === \"\") return null;\n\n const obsTime = m.obsTime;\n if (typeof obsTime !== \"number\" || !Number.isFinite(obsTime)) return null;\n\n const stationCode = icaoToStationCode(icaoId);\n if (!STATION_CODE_RE.test(stationCode)) return null;\n\n // --- observed_at -------------------------------------------------------\n const dt = new Date(obsTime * 1000);\n if (Number.isNaN(dt.getTime())) return null;\n const year = dt.getUTCFullYear();\n if (!(year >= 1970 && year <= 2100)) return null;\n // ISO 8601 with second precision + \"Z\" suffix (matches Python\n // `%Y-%m-%dT%H:%M:%SZ`). `Date.toISOString` emits ms which we strip.\n const observedAt = `${dt.toISOString().slice(0, 19)}Z`;\n\n // --- observation_type --------------------------------------------------\n const metarType = typeof m.metarType === \"string\" ? m.metarType.toUpperCase() : \"METAR\";\n const observationType: \"METAR\" | \"SPECI\" = metarType === \"SPECI\" ? \"SPECI\" : \"METAR\";\n\n // --- Wind direction ----------------------------------------------------\n let wdir: number | null = null;\n const rawWdir = m.wdir;\n if (rawWdir !== null && rawWdir !== undefined) {\n if (typeof rawWdir === \"number\") {\n wdir = boundedInt(Math.trunc(rawWdir), WIND_DIR_LO, WIND_DIR_HI);\n } else if (rawWdir !== \"VRB\") {\n const parsed = Number(rawWdir);\n if (Number.isFinite(parsed)) {\n wdir = boundedInt(Math.trunc(parsed), WIND_DIR_LO, WIND_DIR_HI);\n }\n }\n // \"VRB\" → wdir stays null (Python leaves it None; task spec says\n // \"0 (matches Python convention)\" but the Python source on disk\n // leaves it None — we follow the source-of-truth).\n }\n\n // --- Wind speed / gust -------------------------------------------------\n const wspd = boundedInt(safeInt(m.wspd), 0, WIND_SPEED_MAX);\n const wgst = boundedInt(safeInt(m.wgst), 0, WIND_GUST_MAX);\n\n // --- Altimeter (hPa input → inHg output) -------------------------------\n const altim = hpaToInhg(safeFloat(m.altim));\n\n // --- Sea-level pressure (already mb/hPa) -------------------------------\n let slp = safeFloat(m.slp);\n if (slp !== null && !(slp >= SLP_MIN_MB && slp <= SLP_MAX_MB)) {\n slp = null;\n }\n\n // --- Cloud layers (up to 4) --------------------------------------------\n const clouds = m.clouds ?? [];\n const c0 = clouds[0] !== undefined ? cloudLayer(clouds[0]) : { cover: null, base: null };\n const c1 = clouds[1] !== undefined ? cloudLayer(clouds[1]) : { cover: null, base: null };\n const c2 = clouds[2] !== undefined ? cloudLayer(clouds[2]) : { cover: null, base: null };\n const c3 = clouds[3] !== undefined ? cloudLayer(clouds[3]) : { cover: null, base: null };\n\n // --- Raw METAR (truncate) ----------------------------------------------\n let rawMetar: string | null = null;\n if (typeof m.rawOb === \"string\") {\n rawMetar = m.rawOb.slice(0, MAX_RAW_METAR_LEN);\n }\n\n // --- Weather codes (truncate) ------------------------------------------\n let weatherCodes: string | null = null;\n if (typeof m.wxString === \"string\") {\n weatherCodes = m.wxString.slice(0, MAX_WX_CODES_LEN);\n }\n\n // --- Temperature + dewpoint (T-group overrides body group) -------------\n // Phase 18 PREC-01: when Tgroup is present in raw METAR, the underlying\n // ASOS reading is integer °F (the canonical sensor precision). Recover\n // it directly via Math.round(c * 9/5 + 32) instead of running the float\n // celsiusToFahrenheit() path, which produces back-conversion artifacts\n // like 80.06°F where the native reading was 80°F. When Tgroup is absent\n // (international stations like EGLL / LFPG), fall back to the legacy\n // float celsiusToFahrenheit() path — they have no integer-°F source.\n let tempC = safeFloat(m.temp);\n let dewpC = safeFloat(m.dewp);\n const [tgTemp, tgDewp] = parseTgroup(rawMetar);\n const hasTgroupTemp = tgTemp !== null;\n const hasTgroupDewp = tgDewp !== null;\n if (hasTgroupTemp) tempC = tgTemp;\n if (hasTgroupDewp) dewpC = tgDewp;\n tempC = boundedFloat(tempC, TEMP_MIN_C, TEMP_MAX_C);\n dewpC = boundedFloat(dewpC, TEMP_MIN_C, TEMP_MAX_C);\n const tempF =\n hasTgroupTemp && tempC !== null ? Math.round((tempC * 9) / 5 + 32) : celsiusToFahrenheit(tempC);\n const dewpointF =\n hasTgroupDewp && dewpC !== null ? Math.round((dewpC * 9) / 5 + 32) : celsiusToFahrenheit(dewpC);\n\n // --- Peak wind ---------------------------------------------------------\n const pk = parsePeakWind(rawMetar);\n const pkDir = boundedInt(pk.dir, WIND_DIR_LO, WIND_DIR_HI);\n const pkSpd = boundedInt(pk.speed, 0, WIND_GUST_MAX);\n\n // --- Precip + QC -------------------------------------------------------\n const precip = boundedFloatMin(safePrecip(m.precip), 0.0);\n const qcField = safeInt(m.qcField);\n\n return {\n station_code: stationCode,\n observed_at: observedAt,\n observation_type: observationType,\n source: \"awc\",\n temp_c: tempC,\n dewpoint_c: dewpC,\n temp_f: tempF,\n dewpoint_f: dewpointF,\n wind_dir_degrees: wdir,\n wind_speed_kt: wspd,\n wind_gust_kt: wgst,\n altimeter_inhg: altim,\n sea_level_pressure_mb: slp,\n sky_cover_1: c0.cover,\n sky_base_1_ft: c0.base,\n sky_cover_2: c1.cover,\n sky_base_2_ft: c1.base,\n sky_cover_3: c2.cover,\n sky_base_3_ft: c2.base,\n sky_cover_4: c3.cover,\n sky_base_4_ft: c3.base,\n visibility_miles: parseAwcVisibility(m.visib),\n weather_codes: weatherCodes,\n precip_1hr_inches: precip,\n peak_wind_gust_kt: pkSpd,\n peak_wind_dir: pkDir,\n peak_wind_time: pk.time,\n snow_depth_inches: null, // AWC doesn't carry snow-depth in METARs\n qc_field: qcField,\n raw_metar: rawMetar,\n };\n}\n","// IEM METAR CSV parser — string-body in, Observation rows out.\n//\n// Byte-faithful TS port of Python\n// `packages/weather/src/mostlyright/weather/_iem.py::iem_to_observation`\n// + `parse_iem_file`. The TS port consumes the CSV body in-memory (a\n// string returned by `downloadIemAsos`); the Python version walks a file\n// handle but the semantics are identical.\n//\n// IEM provides pre-parsed METAR fields in US/METAR-native units (°F, kt,\n// mi, inHg). We emit Observation dicts matching the canonical\n// `schema.observation.v1` shape (30 fields, source=\"iem\").\n//\n// Key port details:\n// - Comment lines starting with `#` are stripped BEFORE the CSV header\n// is consumed (mirrors Python's `filtered = (line for line in f if\n// not line.startswith(\"#\"))`).\n// - `M` and empty string both parse as `null` for numeric fields.\n// - `T` (trace) parses as `0.0` for `p01i` only; on other numeric\n// fields, T is non-numeric and yields `null`.\n// - Out-of-bounds Celsius consistency rule: when derived `temp_c` is\n// nulled by bounds, the raw `temp_f` is ALSO nulled (Python L170-171).\n// - All 4 key vars (raw `tmpf`, raw `dwpf`, `wind_speed`, `slp`) missing\n// → row skipped (returns `null` from `iemToObservation`).\n// - Output dict-key order matches Python verbatim so `JSON.stringify`\n// produces byte-stable output across SDKs (downstream diff tooling).\n//\n// CSV implementation: hand-rolled split-on-comma. IEM's `format=comma`\n// endpoint does NOT quote fields with embedded commas (the Python parser\n// confirms this empirically — it uses csv.DictReader with default\n// dialect and works). A hand-rolled split keeps the bundle-size gate\n// happy (no papaparse dep) and matches the TS Architect rubric §2\n// (bundle size) + the plan's \"DO NOT add deps\" note.\n\nimport {\n MAX_RAW_METAR_LEN,\n MAX_VISIBILITY_MILES,\n MAX_WX_CODES_LEN,\n MAX_YEAR,\n MIN_YEAR,\n SKY_BASE_MAX_FT,\n SLP_MAX_MB,\n SLP_MIN_MB,\n STATION_CODE_RE,\n TEMP_MAX_C,\n TEMP_MIN_C,\n WIND_DIR_BOUNDS,\n WIND_GUST_MAX,\n WIND_SPEED_MAX,\n boundedFloat,\n boundedFloatMin,\n boundedInt,\n} from \"@mostlyrightmd/core/internal/bounds\";\nimport { fahrenheitToCelsius } from \"@mostlyrightmd/core/internal/convert\";\n\nimport { parseTgroup } from \"../_internal/tgroup.js\";\nimport { type Observation, icaoToStationCode, mapCloudCover } from \"./awc.js\";\n\nconst [WIND_DIR_LO, WIND_DIR_HI] = WIND_DIR_BOUNDS;\n\n// Match Python `_TS_RE = re.compile(r\"^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})$\")`.\nconst TS_RE = /^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})$/;\n\nconst VALID_OBS_TYPES = new Set<string>([\"METAR\", \"SPECI\"]);\n\nexport type IemObservationTypeOverride = \"METAR\" | \"SPECI\";\n\nexport interface IemToObservationOptions {\n /**\n * Force `observation_type` to this value regardless of metar text.\n * Used by callers that issue separate report_type=3/4 fetches and\n * therefore know the type per-batch without inspecting the raw text.\n */\n observationTypeOverride?: IemObservationTypeOverride;\n}\n\n/** Raw IEM CSV row (header → cell value), all strings before parsing. */\nexport type IemCsvRow = Record<string, string>;\n\n/**\n * Parse an IEM numeric cell into a finite float. `\"M\"` (IEM's missing\n * sentinel) and empty strings yield `null`; non-finite values yield `null`.\n *\n * Mirrors Python `_safe_float`.\n */\nfunction safeFloat(val: string): number | null {\n if (val === \"\" || val === \"M\") return null;\n const f = Number(val);\n return Number.isFinite(f) ? f : null;\n}\n\n/**\n * Parse an IEM numeric cell into a rounded integer via `safeFloat`.\n * `null` propagates. Mirrors Python `_safe_int` (round → int).\n */\nfunction safeInt(val: string): number | null {\n const f = safeFloat(val);\n return f === null ? null : Math.round(f);\n}\n\n/**\n * Parse the IEM precipitation cell. `M` / empty → `null`; `T` (trace) → `0.0`;\n * numeric values pass through `safeFloat`. Mirrors Python `_parse_precip`.\n */\nfunction parsePrecip(val: string): number | null {\n if (val === \"\" || val === \"M\") return null;\n if (val.trim().toUpperCase() === \"T\") return 0.0;\n return safeFloat(val);\n}\n\n/**\n * Parse IEM timestamp (`YYYY-MM-DD HH:MM`) into RFC3339 / ISO 8601 UTC.\n * Returns `null` for missing (`\"\"`/`M`), malformed, calendar-invalid, or\n * out-of-year-range inputs. Mirrors Python `_parse_timestamp`.\n */\nfunction parseTimestamp(val: string): string | null {\n if (val === \"\" || val === \"M\") return null;\n const trimmed = val.trim();\n const m = TS_RE.exec(trimmed);\n if (m === null) return null;\n // RegExp matched — groups 1..5 are guaranteed defined.\n const year = Number.parseInt(m[1] as string, 10);\n const month = Number.parseInt(m[2] as string, 10);\n const day = Number.parseInt(m[3] as string, 10);\n const hour = Number.parseInt(m[4] as string, 10);\n const minute = Number.parseInt(m[5] as string, 10);\n // Range checks BEFORE Date.UTC round-trip (rejects Feb-30 / hour-25 inputs\n // that Date.UTC would otherwise silently roll forward).\n if (year < MIN_YEAR || year > MAX_YEAR) return null;\n if (month < 1 || month > 12) return null;\n if (day < 1 || day > 31) return null;\n if (hour > 23 || minute > 59) return null;\n // Calendar validity via UTC round-trip — rejects \"2025-02-30 12:00\" etc.\n const millis = Date.UTC(year, month - 1, day, hour, minute, 0, 0);\n if (!Number.isFinite(millis)) return null;\n const d = new Date(millis);\n if (\n d.getUTCFullYear() !== year ||\n d.getUTCMonth() !== month - 1 ||\n d.getUTCDate() !== day ||\n d.getUTCHours() !== hour ||\n d.getUTCMinutes() !== minute\n ) {\n return null;\n }\n // Format as `YYYY-MM-DDTHH:MM:00Z` byte-faithful with Python's f-string.\n return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00Z`;\n}\n\n/**\n * Parse `YYYY-MM-DD HH:MM` peak-wind-time into the bare `HHMM` form Python\n * emits. Returns `null` on `\"\"`/`M`/malformed. Mirrors Python\n * `_parse_peak_wind_time`.\n */\nfunction parsePeakWindTime(val: string): string | null {\n if (val === \"\" || val === \"M\") return null;\n const trimmed = val.trim();\n const parts = trimmed.split(\" \");\n if (parts.length !== 2) return null;\n const timeParts = (parts[1] as string).split(\":\");\n if (timeParts.length !== 2) return null;\n const h = Number.parseInt(timeParts[0] as string, 10);\n const min = Number.parseInt(timeParts[1] as string, 10);\n if (!Number.isFinite(h) || !Number.isFinite(min)) return null;\n if (h < 0 || h > 23 || min < 0 || min > 59) return null;\n const hh = h < 10 ? `0${h}` : String(h);\n const mm = min < 10 ? `0${min}` : String(min);\n return `${hh}${mm}`;\n}\n\n/**\n * Detect METAR vs SPECI from the raw METAR text first word. Empty / `M` →\n * `\"METAR\"` (safe default). Mirrors Python `_detect_obs_type`.\n */\nfunction detectObsType(metar: string): \"METAR\" | \"SPECI\" {\n if (metar === \"\" || metar === \"M\") return \"METAR\";\n const words = metar.trim().split(/\\s+/, 1);\n if (words.length > 0 && words[0] === \"SPECI\") return \"SPECI\";\n return \"METAR\";\n}\n\n/**\n * Convert one IEM CSV row (header→cell dict) into the canonical\n * Observation row. Returns `null` if the row should be skipped:\n * - station missing/M, or doesn't match STATION_CODE_RE after K-strip\n * - timestamp unparseable / out-of-range\n * - ALL 4 key vars (raw tmpf, raw dwpf, wind_speed, slp) missing\n *\n * Throws on bad `observationTypeOverride` (Python L152-156 parity).\n *\n * Output dict-key order is preserved verbatim — matters for downstream\n * byte-stable JSON snapshot diffs.\n */\nexport function iemToObservation(\n row: IemCsvRow,\n opts: IemToObservationOptions = {},\n): Observation | null {\n // --- Station code ------------------------------------------------------\n const stationRaw = row.station ?? \"\";\n if (stationRaw === \"\" || stationRaw === \"M\") return null;\n const stationCode = icaoToStationCode(stationRaw);\n if (!STATION_CODE_RE.test(stationCode)) return null;\n\n // --- Timestamp ---------------------------------------------------------\n const observedAt = parseTimestamp(row.valid ?? \"\");\n if (observedAt === null) return null;\n\n // --- Observation type (caller override or auto-detect) ----------------\n const override = opts.observationTypeOverride;\n if (override !== undefined && !VALID_OBS_TYPES.has(override)) {\n throw new Error(\n `Invalid observation_type_override: ${JSON.stringify(\n override,\n )}. Must be one of ${[...VALID_OBS_TYPES].join(\", \")}`,\n );\n }\n const metarText = row.metar ?? \"\";\n const observationType: \"METAR\" | \"SPECI\" =\n override !== undefined ? override : detectObsType(metarText);\n\n // --- Temperature (IEM gives °F; derive °C; bounds-check on °C) ---------\n // Phase 18 PREC-02: when raw METAR contains Tgroup, override temp_c /\n // dewpoint_c with the Tgroup tenths-°C value (matches AWC's emitted\n // temp_c for the same raw METAR — closes the cross-source drift bug).\n //\n // CRITICAL gates (mirror Python `_iem.py:164-188` verbatim):\n // 1. Compute fahrenheit_to_celsius(rawTempF) FIRST and bound. This nulls\n // temp_c when raw tmpf was missing or out of bounds (e.g. tmpf=2000).\n // 2. Apply Tgroup override ONLY when both `tgTemp !== null` AND the\n // raw-derived `tempC !== null`. Without this gate, a row with\n // tmpf=\"M\" (missing) but a valid Tgroup in `metar` would silently\n // emit temp_c=26.7 while Python emits null — cross-SDK parity drift.\n // 3. Re-bound the Tgroup-overridden value (Python applies bounded_float\n // on the Tgroup itself too).\n //\n // tempF = rawTempF (NOT derived from temp_c). tempC (tenths-°C) and\n // tempF (integer-°F) are different coded views of the same integer-°F\n // sensor reading; deriving one from the other introduces a spurious\n // tempC × 9/5 + 32 == tempF invariant the source data does not carry.\n const rawTempF = safeFloat(row.tmpf ?? \"\");\n const rawDewpF = safeFloat(row.dwpf ?? \"\");\n let tempC = boundedFloat(fahrenheitToCelsius(rawTempF), TEMP_MIN_C, TEMP_MAX_C, {\n field: \"temp_c\",\n });\n let dewpC = boundedFloat(fahrenheitToCelsius(rawDewpF), TEMP_MIN_C, TEMP_MAX_C, {\n field: \"dewpoint_c\",\n });\n const [tgTemp, tgDewp] = parseTgroup(metarText);\n if (tgTemp !== null && tempC !== null) {\n tempC = boundedFloat(tgTemp, TEMP_MIN_C, TEMP_MAX_C, { field: \"temp_c\" });\n }\n if (tgDewp !== null && dewpC !== null) {\n dewpC = boundedFloat(tgDewp, TEMP_MIN_C, TEMP_MAX_C, { field: \"dewpoint_c\" });\n }\n // Consistency rule (Python L170-171): if derived °C is null (raw or\n // Tgroup-overridden), the raw °F is also bogus — null both sides.\n const tempF: number | null = tempC !== null ? rawTempF : null;\n const dewpF: number | null = dewpC !== null ? rawDewpF : null;\n\n // --- Wind (already in knots) ------------------------------------------\n const windDir = boundedInt(safeInt(row.drct ?? \"\"), WIND_DIR_LO, WIND_DIR_HI);\n const windSpeed = boundedInt(safeInt(row.sknt ?? \"\"), 0, WIND_SPEED_MAX);\n const windGust = boundedInt(safeInt(row.gust ?? \"\"), 0, WIND_GUST_MAX);\n\n // --- Pressure ----------------------------------------------------------\n const altim = safeFloat(row.alti ?? \"\"); // inHg (passthrough)\n let slp = safeFloat(row.mslp ?? \"\"); // mb / hPa\n if (slp !== null && !(slp >= SLP_MIN_MB && slp <= SLP_MAX_MB)) {\n slp = null;\n }\n\n // --- Visibility (statute miles; clamp at MAX_VISIBILITY_MILES) --------\n let vis = safeFloat(row.vsby ?? \"\");\n if (vis !== null) {\n if (vis < 0) vis = null;\n else if (vis > MAX_VISIBILITY_MILES) vis = MAX_VISIBILITY_MILES;\n }\n\n // --- Sky covers + base heights (4 columns; IEM gives feet) ------------\n const skyCovers: Array<string | null> = [];\n const skyBases: Array<number | null> = [];\n for (let i = 1; i <= 4; i += 1) {\n const coverRaw = row[`skyc${i}`] ?? \"\";\n const baseRaw = row[`skyl${i}`] ?? \"\";\n const cover = coverRaw !== \"\" && coverRaw !== \"M\" ? mapCloudCover(coverRaw) : null;\n const base = boundedInt(safeInt(baseRaw), 0, SKY_BASE_MAX_FT);\n skyCovers.push(cover);\n skyBases.push(base);\n }\n\n // --- Weather codes (truncate to MAX_WX_CODES_LEN) ---------------------\n const wxRaw = row.wxcodes ?? \"\";\n const weatherCodes: string | null =\n wxRaw !== \"\" && wxRaw !== \"M\" ? wxRaw.slice(0, MAX_WX_CODES_LEN) : null;\n\n // --- Precipitation ('T' = trace → 0.0; clamp at 0.0) ------------------\n const precip = boundedFloatMin(parsePrecip(row.p01i ?? \"\"), 0.0);\n\n // --- Snow depth (clamp at 0.0) ----------------------------------------\n const snow = boundedFloatMin(safeFloat(row.snowdepth ?? \"\"), 0.0);\n\n // --- Peak wind ---------------------------------------------------------\n const pkGust = boundedInt(safeInt(row.peak_wind_gust ?? \"\"), 0, WIND_GUST_MAX);\n const pkDir = boundedInt(safeInt(row.peak_wind_drct ?? \"\"), WIND_DIR_LO, WIND_DIR_HI);\n const pkTime = parsePeakWindTime(row.peak_wind_time ?? \"\");\n\n // --- Raw METAR (truncate) ---------------------------------------------\n const rawMetar: string | null =\n metarText !== \"\" && metarText !== \"M\" ? metarText.slice(0, MAX_RAW_METAR_LEN) : null;\n\n // --- Skip-row gate: ALL 4 key vars missing → drop the row -------------\n // Mirror Python L222-224: uses the RAW values (raw_temp_f, raw_dewp_f,\n // wind_speed bounded, slp bounded). Note slp uses post-bounds null so a\n // pressure outside [870, 1084] also counts as missing for skip purposes.\n if (rawTempF === null && rawDewpF === null && windSpeed === null && slp === null) {\n return null;\n }\n\n // Return literal preserves Python's iem_to_observation key order\n // exactly — 30 fields. JSON.stringify ordering matters for downstream\n // byte-stable diff tooling.\n return {\n station_code: stationCode,\n observed_at: observedAt,\n observation_type: observationType,\n source: \"iem\",\n temp_c: tempC,\n dewpoint_c: dewpC,\n temp_f: tempF,\n dewpoint_f: dewpF,\n wind_dir_degrees: windDir,\n wind_speed_kt: windSpeed,\n wind_gust_kt: windGust,\n altimeter_inhg: altim,\n sea_level_pressure_mb: slp,\n sky_cover_1: skyCovers[0] as string | null,\n sky_base_1_ft: skyBases[0] as number | null,\n sky_cover_2: skyCovers[1] as string | null,\n sky_base_2_ft: skyBases[1] as number | null,\n sky_cover_3: skyCovers[2] as string | null,\n sky_base_3_ft: skyBases[2] as number | null,\n sky_cover_4: skyCovers[3] as string | null,\n sky_base_4_ft: skyBases[3] as number | null,\n visibility_miles: vis,\n weather_codes: weatherCodes,\n precip_1hr_inches: precip,\n peak_wind_gust_kt: pkGust,\n peak_wind_dir: pkDir,\n peak_wind_time: pkTime,\n snow_depth_inches: snow,\n qc_field: null, // IEM CSV doesn't carry a QC field — schema-stable null\n raw_metar: rawMetar,\n };\n}\n\n/**\n * Parse a full IEM CSV body into Observation rows.\n *\n * - Lines beginning with `#` are dropped before the header is consumed\n * (mirror Python `filtered = (line for line in f if not line.startswith(\"#\"))`).\n * - The first non-comment line is the header; subsequent lines are data.\n * - Rows that `iemToObservation` rejects are silently dropped (parser\n * never throws on bad data; only `observationTypeOverride` validation\n * throws — that's a programmer-error path).\n *\n * Returns an empty array for empty / header-only / all-comments input.\n */\nexport function parseIemCsv(\n csvBody: string,\n opts: IemToObservationOptions = {},\n): ReadonlyArray<Observation> {\n if (csvBody === \"\") return [];\n // Normalize line endings + split. Drop empty trailing lines so trailing\n // newlines don't yield phantom rows.\n const lines = csvBody.split(/\\r?\\n/).filter((line) => !line.startsWith(\"#\"));\n // Skip empty lines (incl. the final newline tail).\n const nonEmpty = lines.filter((line) => line.length > 0);\n if (nonEmpty.length === 0) return [];\n const headerLine = nonEmpty[0] as string;\n const header = headerLine.split(\",\");\n const out: Observation[] = [];\n for (let i = 1; i < nonEmpty.length; i += 1) {\n const line = nonEmpty[i] as string;\n const cells = line.split(\",\");\n const row: IemCsvRow = {};\n for (let c = 0; c < header.length; c += 1) {\n const key = header[c] as string;\n row[key] = (cells[c] ?? \"\").trim();\n }\n const obs = iemToObservation(row, opts);\n if (obs !== null) out.push(obs);\n }\n return out;\n}\n"],"mappings":"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","names":["n","WIND_DIR_LO","WIND_DIR_HI","safeFloat","safeInt"]}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
iemToObservation,
|
|
3
3
|
parseIemCsv
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-TREZXL75.mjs";
|
|
5
5
|
import "./chunk-VESWR46G.mjs";
|
|
6
6
|
import "./chunk-J5LGTIGS.mjs";
|
|
7
7
|
export {
|
|
8
8
|
iemToObservation,
|
|
9
9
|
parseIemCsv
|
|
10
10
|
};
|
|
11
|
-
//# sourceMappingURL=iem-
|
|
11
|
+
//# sourceMappingURL=iem-IO2HIL5V.mjs.map
|
package/dist/index.bundle.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
mapCloudCover,
|
|
29
29
|
parseAwcVisibility,
|
|
30
30
|
parseIemCsv
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-TREZXL75.mjs";
|
|
32
32
|
import {
|
|
33
33
|
MAX_RAW_METAR_LEN,
|
|
34
34
|
MAX_VISIBILITY_MILES,
|
|
@@ -765,7 +765,7 @@ async function fetchIemLatest(station) {
|
|
|
765
765
|
import("./src-NP2MZ322.mjs"),
|
|
766
766
|
import("./bounds-KSTXL77E.mjs"),
|
|
767
767
|
import("./iem-asos-ZPUMH3KM.mjs"),
|
|
768
|
-
import("./iem-
|
|
768
|
+
import("./iem-IO2HIL5V.mjs")
|
|
769
769
|
]);
|
|
770
770
|
const icao = normalizeStation(station);
|
|
771
771
|
const stationCode = icao.length === 4 && icao.startsWith("K") ? icao.slice(1) : icao;
|
|
@@ -886,13 +886,7 @@ async function* stream(station, opts = {}) {
|
|
|
886
886
|
|
|
887
887
|
// ../weather/src/forecasts/iem-mos.ts
|
|
888
888
|
var IEM_MOS_URL = "https://mesonet.agron.iastate.edu/api/1/mos.json";
|
|
889
|
-
var SUPPORTED_MODELS = /* @__PURE__ */ new Set([
|
|
890
|
-
"nbe",
|
|
891
|
-
"gfs",
|
|
892
|
-
"lav",
|
|
893
|
-
"met",
|
|
894
|
-
"ecm"
|
|
895
|
-
]);
|
|
889
|
+
var SUPPORTED_MODELS = /* @__PURE__ */ new Set(["nbe", "gfs", "lav", "met", "ecm"]);
|
|
896
890
|
var KT_TO_MS = 0.5144444;
|
|
897
891
|
var NBE_CYCLE_CUTOVER = Date.UTC(2026, 5 - 1, 5, 0, 0, 0);
|
|
898
892
|
function runtimeHoursFor(model, fromDt, toDt) {
|
|
@@ -935,9 +929,7 @@ function parseRow(raw, station, model, retrievedAt) {
|
|
|
935
929
|
const issuedDt = parseDate(raw.runtime ?? raw.model_runtime);
|
|
936
930
|
const validDt = parseDate(raw.ftime ?? raw.valid_time);
|
|
937
931
|
if (issuedDt === null || validDt === null) return null;
|
|
938
|
-
const forecastHour = Math.round(
|
|
939
|
-
(validDt.getTime() - issuedDt.getTime()) / 36e5
|
|
940
|
-
);
|
|
932
|
+
const forecastHour = Math.round((validDt.getTime() - issuedDt.getTime()) / 36e5);
|
|
941
933
|
return {
|
|
942
934
|
station,
|
|
943
935
|
model: model.toUpperCase(),
|
|
@@ -960,9 +952,7 @@ function parseRow(raw, station, model, retrievedAt) {
|
|
|
960
952
|
function parseIsoDate(iso, endOfDay) {
|
|
961
953
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
962
954
|
if (match === null) {
|
|
963
|
-
throw new Error(
|
|
964
|
-
`iemMosForecasts: from/to dates must be ISO YYYY-MM-DD; got ${iso}`
|
|
965
|
-
);
|
|
955
|
+
throw new Error(`iemMosForecasts: from/to dates must be ISO YYYY-MM-DD; got ${iso}`);
|
|
966
956
|
}
|
|
967
957
|
const [, y, m, d] = match;
|
|
968
958
|
if (endOfDay) {
|
|
@@ -991,15 +981,13 @@ async function iemMosForecasts(station, fromDate, toDate, opts = {}) {
|
|
|
991
981
|
if (rt < fromDt || rt > toDt) continue;
|
|
992
982
|
const url = `${IEM_MOS_URL}?station=${encodeURIComponent(
|
|
993
983
|
station
|
|
994
|
-
)}&model=${encodeURIComponent(model)}&runtime=${encodeURIComponent(
|
|
984
|
+
)}&model=${encodeURIComponent(model.toUpperCase())}&runtime=${encodeURIComponent(
|
|
995
985
|
rt.toISOString()
|
|
996
986
|
)}`;
|
|
997
987
|
const resp = await fetchFn(url);
|
|
998
988
|
if (resp.status === 404) continue;
|
|
999
989
|
if (!resp.ok) {
|
|
1000
|
-
throw new Error(
|
|
1001
|
-
`iemMosForecasts: HTTP ${resp.status} on ${url}`
|
|
1002
|
-
);
|
|
990
|
+
throw new Error(`iemMosForecasts: HTTP ${resp.status} on ${url}`);
|
|
1003
991
|
}
|
|
1004
992
|
const payload = await resp.json();
|
|
1005
993
|
for (const raw of payload.data ?? []) {
|