From 5b6636fe01a785e3214833dcd1418cb193e06a4b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 26 Feb 2026 09:19:35 -0500 Subject: [PATCH] fixes for compound constituent name parser, correct 2 naming constituent naming errors --- .../src/constituents/compound.ts | 111 +++++++++++----- .../tide-predictor/src/constituents/data.json | 15 +-- .../test/constituents/compound.test.ts | 118 ++++++++++++++---- .../test/constituents/index.test.ts | 68 ++++++++++ 4 files changed, 245 insertions(+), 67 deletions(-) diff --git a/packages/tide-predictor/src/constituents/compound.ts b/packages/tide-predictor/src/constituents/compound.ts index 1179c91e..b223b1d5 100644 --- a/packages/tide-predictor/src/constituents/compound.ts +++ b/packages/tide-predictor/src/constituents/compound.ts @@ -58,21 +58,15 @@ const K2_INFO: LetterInfo = { species: 2 }; * Throws for names that cannot be decomposed — any constituent with nodal * correction code "x" must have a parseable compound name. * - * IHO Annex B exception: MA and MB constituents are annual variants that - * follow the same decomposition as their base M constituent. + * Note: MA/MB annual variants are handled by decomposeCompound before + * reaching parseName, so they never enter this function. */ export function parseName(name: string): { tokens: ParsedToken[]; targetSpecies: number } { const fail = (reason: string): Error => new Error(`Unable to parse compound constituent "${name}": ${reason}`); - // IHO Annex B exception: Normalize MA/MB annual variants to M - let normalizedName = name; - if ((name.startsWith("MA") || name.startsWith("MB")) && name.length > 2) { - normalizedName = "M" + name.substring(2); - } - // Extract trailing species number - const m = normalizedName.match(/^(.+?)(\d+)$/); + const m = name.match(/^(.+?)(\d+)$/); if (!m) throw fail("no trailing species digits"); const body = m[1]; @@ -147,6 +141,15 @@ function isLower(ch: string): boolean { return ch >= "a" && ch <= "z"; } +function popcount(n: number): number { + let count = 0; + while (n) { + count += n & 1; + n >>= 1; + } + return count; +} + function isKnownLetter(letter: string): boolean { // A and B are not compound letters per Annex B exceptions if (letter === "A" || letter === "B") return false; @@ -159,39 +162,45 @@ function isKnownLetter(letter: string): boolean { * Resolve component signs using the IHO Annex B progressive right-to-left * sign-flipping algorithm. * - * For K (ambiguous between K1 and K2), tries K2 first then K1. + * For K (ambiguous between K1 and K2), tries all 2^N combinations of K1/K2 + * per K token, starting with all-K2 (most common in even-species compounds). */ export function resolveSigns( tokens: ParsedToken[], targetSpecies: number, ): ResolvedComponent[] | null { - const hasK = tokens.some((t) => t.letter === "K"); + const kIndices: number[] = []; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].letter === "K") kIndices.push(i); + } - if (hasK) { - // Try K2 first (more common in even-species compounds) - const result = tryResolve(tokens, targetSpecies, K2_INFO); + const nK = kIndices.length; + const nCombinations = nK > 0 ? 1 << nK : 1; + + for (let kMask = 0; kMask < nCombinations; kMask++) { + const infos = tokens.map((t, i) => { + if (t.letter !== "K") return LETTER_MAP[t.letter]; + const ki = kIndices.indexOf(i); + return kMask & (1 << ki) ? K1_INFO : K2_INFO; + }); + const result = tryResolve(tokens, targetSpecies, infos); if (result) return result; - // Fall back to K1 - return tryResolve(tokens, targetSpecies, K1_INFO); } - return tryResolve(tokens, targetSpecies, K2_INFO); + return null; } function tryResolve( tokens: ParsedToken[], targetSpecies: number, - kInfo: LetterInfo, + infos: LetterInfo[], ): ResolvedComponent[] | null { - const infos = tokens.map((t) => (t.letter === "K" ? kInfo : LETTER_MAP[t.letter])); - /** Derive constituent key: letter + species (e.g. "M2", "S2", "K1") */ const keyOf = (j: number) => tokens[j].letter + infos[j].species; - // Single-letter overtide: e.g. M4 = M2 × M2 + // Single-letter overtide: e.g. M4 = 2×M2, M6 = 3×M2 if (tokens.length === 1) { - const info = infos[0]; - const letterSpecies = info.species; + const letterSpecies = infos[0].species; if (letterSpecies > 0 && targetSpecies > letterSpecies) { return [ { @@ -200,15 +209,6 @@ function tryResolve( }, ]; } - // Single letter, species matches directly (shouldn't normally be "x" code) - if (letterSpecies === targetSpecies) { - return [ - { - constituentKey: keyOf(0), - factor: 1, - }, - ]; - } } // Progressive right-to-left sign flip (IHO Annex B) @@ -224,7 +224,38 @@ function tryResolve( total -= 2 * tokens[j].multiplier * infos[j].species; } - if (total !== targetSpecies) return null; + if (total !== targetSpecies) { + // Brute-force fallback: try all 2^N sign combinations. + // Handles non-contiguous patterns like [+, -, +] that the + // right-to-left heuristic misses. Collect all valid combinations + // and prefer fewest negatives, with negatives on later tokens + // (matching the IHO convention where leading letters are positive). + const n = tokens.length; + const valid: number[] = []; + for (let mask = 0; mask < 1 << n; mask++) { + let sum = 0; + for (let j = 0; j < n; j++) { + const sign = mask & (1 << j) ? -1 : 1; + sum += sign * tokens[j].multiplier * infos[j].species; + } + if (sum === targetSpecies) valid.push(mask); + } + if (valid.length > 0) { + valid.sort((a, b) => { + const popA = popcount(a); + const popB = popcount(b); + if (popA !== popB) return popA - popB; + // Among same popcount, prefer negatives on later tokens (higher bits) + return b - a; + }); + const mask = valid[0]; + return tokens.map((t, j) => ({ + constituentKey: keyOf(j), + factor: ((mask & (1 << j) ? -1 : 1) as 1 | -1) * t.multiplier, + })); + } + return null; + } return tokens.map((t, j) => ({ constituentKey: keyOf(j), @@ -252,6 +283,20 @@ export function decomposeCompound( species: number, constituents: Record, ): ConstituentMember[] | null { + // MA/MB annual variants: overtide of M2 with annual modulation (±Sa). + // MA{n} = (n/2)×M2 − Sa, MB{n} = (n/2)×M2 + Sa. + const maMatch = name.match(/^M([AB])(\d+)$/); + if (maMatch) { + const [, variant, speciesStr] = maMatch; + const m2 = constituents.M2; + const sa = constituents.Sa; + if (!m2 || !sa) return null; + return [ + { constituent: m2, factor: parseInt(speciesStr, 10) / 2 }, + { constituent: sa, factor: variant === "A" ? -1 : 1 }, + ]; + } + let parsed: ReturnType; try { parsed = parseName(name); diff --git a/packages/tide-predictor/src/constituents/data.json b/packages/tide-predictor/src/constituents/data.json index 636358c3..a0cf5fad 100644 --- a/packages/tide-predictor/src/constituents/data.json +++ b/packages/tide-predictor/src/constituents/data.json @@ -2678,11 +2678,11 @@ "aliases": ["4MNS12"] }, { - "name": "4ML12", - "speed": 174.449, - "xdo": null, + "name": "5ML12", + "speed": 174.448999822, + "xdo": [12, 6, 5, 4, 5, 5, 7], "nodalCorrection": "x", - "aliases": [] + "aliases": ["4ML12"] }, { "name": "4MNK12", @@ -2740,13 +2740,6 @@ "nodalCorrection": "x", "aliases": [] }, - { - "name": "5MSN12", - "speed": 175.547033, - "xdo": null, - "nodalCorrection": "x", - "aliases": [] - }, { "name": "4MST12", "speed": 175.8953503, diff --git a/packages/tide-predictor/test/constituents/compound.test.ts b/packages/tide-predictor/test/constituents/compound.test.ts index 3122e6bc..d45e5aa5 100644 --- a/packages/tide-predictor/test/constituents/compound.test.ts +++ b/packages/tide-predictor/test/constituents/compound.test.ts @@ -118,23 +118,11 @@ describe("parseName", () => { }); }); - it("parses MA/MB annual variants (IHO Annex B)", () => { - // MA4 is normalized to M4 before parsing - expect(parseName("MA4")).toEqual({ - tokens: [{ letter: "M", multiplier: 1 }], - targetSpecies: 4, - }); - // MB5 is normalized to M5 before parsing - expect(parseName("MB5")).toEqual({ - tokens: [{ letter: "M", multiplier: 1 }], - targetSpecies: 5, - }); - }); - it("throws for unknown letter outside parenthesized group", () => { - // A and B are only valid as part of MA/MB pattern + // A and B are not compound letters (MA/MB handled by decomposeCompound) expect(() => parseName("A4")).toThrow('unknown letter "A"'); expect(() => parseName("B5")).toThrow('unknown letter "B"'); + expect(() => parseName("MA4")).toThrow('unknown letter "A"'); expect(() => parseName("SA4")).toThrow('unknown letter "A"'); }); @@ -460,35 +448,43 @@ describe("decomposeCompound", () => { expect(result![1].factor).toBe(1); }); - it("decomposes MA annual variants (IHO Annex B)", () => { - // MA4 should decompose as M4 (= M2 × M2) + it("decomposes MA annual variants as (n/2)×M2 - Sa (IHO Annex B)", () => { + // MA4 = 2×M2 - Sa const ma4 = decomposeCompound("MA4", 4, constituents); expect(ma4).not.toBeNull(); - expect(ma4).toHaveLength(1); + expect(ma4).toHaveLength(2); expect(ma4![0].constituent).toBe(constituents.M2); expect(ma4![0].factor).toBe(2); + expect(ma4![1].constituent).toBe(constituents.Sa); + expect(ma4![1].factor).toBe(-1); - // MA6 should decompose as M6 (= M2 × M2 × M2) + // MA6 = 3×M2 - Sa const ma6 = decomposeCompound("MA6", 6, constituents); expect(ma6).not.toBeNull(); - expect(ma6).toHaveLength(1); + expect(ma6).toHaveLength(2); expect(ma6![0].factor).toBe(3); + expect(ma6![1].constituent).toBe(constituents.Sa); + expect(ma6![1].factor).toBe(-1); }); it("decomposes MB/MA annual variants with fractional factors", () => { - // MB5 normalizes to M5 = M2 × 2.5 + // MB5 = 2.5×M2 + Sa const mb5 = decomposeCompound("MB5", 5, constituents); expect(mb5).not.toBeNull(); - expect(mb5).toHaveLength(1); + expect(mb5).toHaveLength(2); expect(mb5![0].constituent).toBe(constituents.M2); expect(mb5![0].factor).toBe(2.5); + expect(mb5![1].constituent).toBe(constituents.Sa); + expect(mb5![1].factor).toBe(1); - // MA9 normalizes to M9 = M2 × 4.5 + // MA9 = 4.5×M2 - Sa const ma9 = decomposeCompound("MA9", 9, constituents); expect(ma9).not.toBeNull(); - expect(ma9).toHaveLength(1); + expect(ma9).toHaveLength(2); expect(ma9![0].constituent).toBe(constituents.M2); expect(ma9![0].factor).toBe(4.5); + expect(ma9![1].constituent).toBe(constituents.Sa); + expect(ma9![1].factor).toBe(-1); }); it("returns null for unparseable names", () => { @@ -532,6 +528,82 @@ describe("decomposeCompound", () => { expect(oq2![0].constituent).toBe(constituents.O1); expect(oq2![1].constituent).toBe(constituents.Q1); }); + + function memberSpeed(members: { constituent: { speed: number }; factor: number }[]) { + return members.reduce((sum, m) => sum + m.factor * m.constituent.speed, 0); + } + + // Sign pattern [+3, -3, +1] not reachable by right-to-left flip algorithm. + // Doodson: (2, 5, -6, 1, 0, 0) → 3×S2 - 3×M2 + N2 + it("3(SM)N2 = 3×S2 - 3×M2 + N2", () => { + const result = decomposeCompound("3(SM)N2", 0, constituents); + expect(result).not.toBeNull(); + expect(result).toHaveLength(3); + expect(memberSpeed(result!)).toBeCloseTo(constituents["3(SM)N2"].speed, 6); + }); + + // Both K tokens must resolve independently: first K→K1, second K→K2. + // Doodson: (5, 5, -2, 0, 0, 0) → S2 + K1 + K2 + it("(SK)K5 = S2 + K1 + K2", () => { + const result = decomposeCompound("(SK)K5", 0, constituents); + expect(result).not.toBeNull(); + expect(result).toHaveLength(3); + expect(result![0].constituent).toBe(constituents.S2); + expect(result![1].constituent).toBe(constituents.K1); + expect(result![2].constituent).toBe(constituents.K2); + expect(memberSpeed(result!)).toBeCloseTo(constituents["(SK)K5"].speed, 6); + }); + + // IHO name "4ML12" is a naming error — should be 5ML12 (5×M2 + L2). + // TideHarmonics uses the corrected name. 4ML12 is kept as an alias. + // Name now decomposes correctly: 5×M + L = 10+2 = 12. + it("5ML12 = 5×M2 + L2 (IHO name corrected from 4ML12)", () => { + const c = constituents["5ML12"]; + expect(c.members).toHaveLength(2); + expect(c.members[0].constituent).toBe(constituents.M2); + expect(c.members[0].factor).toBe(5); + expect(c.members[1].constituent).toBe(constituents.L2); + expect(c.members[1].factor).toBe(1); + expect(memberSpeed(c.members)).toBeCloseTo(c.speed, 6); + // Old IHO name still accessible via alias + expect(constituents["4ML12"]).toBe(c); + }); + + // IHO "5MSN12" is a naming error — Doodson (12,3,0,-1) has h=0, ruling + // out S2 (h=-2). The real composition is 6×M2 + Mfm. TideHarmonics + // omits this entry entirely. We drop it too (6MSN12 is the valid 12th- + // diurnal M+S-N compound). + it("5MSN12 is dropped (naming error in IHO list)", () => { + expect(constituents["5MSN12"]).toBeUndefined(); + }); + + // Per IHO Annex B: 3×N2 + 2×M2 + S2, all positive (species 6+4+2=12). + // The stored speed (173.362) differs from the member sum (173.287) by + // 0.075°/hr — a data discrepancy, not a parser issue. + it("3N2MS12 = 3×N2 + 2×M2 + S2 (IHO Annex B)", () => { + const result = decomposeCompound("3N2MS12", 0, constituents); + expect(result).not.toBeNull(); + expect(result).toHaveLength(3); + expect(result![0].constituent).toBe(constituents.N2); + expect(result![0].factor).toBe(3); + expect(result![1].constituent).toBe(constituents.M2); + expect(result![1].factor).toBe(2); + expect(result![2].constituent).toBe(constituents.S2); + expect(result![2].factor).toBe(1); + }); + + // MA normalization strips "A" → "M12" → 6×M2, but MA12 is actually + // the annual variant: 6×M2 - Sa. Doodson differs in h coefficient. + it("MA12 = 6×M2 - Sa (annual modulation)", () => { + const result = decomposeCompound("MA12", 0, constituents); + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + expect(result![0].constituent).toBe(constituents.M2); + expect(result![0].factor).toBe(6); + expect(result![1].constituent).toBe(constituents.Sa); + expect(result![1].factor).toBe(-1); + expect(memberSpeed(result!)).toBeCloseTo(constituents.MA12.speed, 6); + }); }); // ─── All "x" constituents ─────────────────────────────────────────────────── diff --git a/packages/tide-predictor/test/constituents/index.test.ts b/packages/tide-predictor/test/constituents/index.test.ts index 13d3ce10..43450f30 100644 --- a/packages/tide-predictor/test/constituents/index.test.ts +++ b/packages/tide-predictor/test/constituents/index.test.ts @@ -237,4 +237,72 @@ describe("Base constituent definitions", () => { expect(Math.abs(computedSpeed - c.speed), `${name} speed`).toBeLessThan(0.0001); } }); + + // Deduplicate: aliases point to the same object as the canonical name + const allConstituents = [...new Set(Object.values(constituents))]; + + // Use a non-J2000 epoch (T=0 causes NaN in derivative polynomial) + const a = astro(sampleTime); + const argSpeeds = [ + a["T+h-s"].speed, // τ + a.s.speed, // s + a.h.speed, // h + a.p.speed, // p + -a.N.speed, // N' = −N + a.pp.speed, // p' + ]; + + /** Compute speed from Doodson coefficients, recursing through members for compounds. */ + function computeSpeed(c: (typeof allConstituents)[number]): number { + if (c.coefficients) { + let speed = 0; + for (let i = 0; i < 6; i++) { + speed += c.coefficients[i] * argSpeeds[i]; + } + return speed; + } + let speed = 0; + for (const { constituent: member, factor } of c.members) { + speed += factor * computeSpeed(member); + } + return speed; + } + + describe("speed derived from Doodson coefficients", () => { + const withCoefficients = allConstituents.filter((c) => c.coefficients !== null); + + it.each(withCoefficients.map((c) => ({ name: c.name, speed: c.speed })))( + "$name speed matches coefficients", + ({ name, speed }) => { + const computed = computeSpeed(constituents[name]); + // Use the decimal precision of the stored speed constant, capped to + // account for polynomial approximation drift at the given epoch. + // Astronomical argument speeds are polynomial derivatives that vary + // slightly from the exact mean speeds (~3e-7 max drift). + const decimals = speed.toPrecision(12).replace(/0+$/, "").split(".")[1]?.length ?? 0; + expect(computed).toBeCloseTo(speed, Math.max(Math.min(decimals - 1, 6), 1)); + }, + ); + }); + + describe("speed derived from compound members", () => { + // These constituents have IHO Doodson numbers that don't match + // their IHO Annex B name decomposition, so the member-derived + // speed diverges from the stored speed. + const nameSpeedMismatch = new Set(["3N2MS12"]); + + const compounds = allConstituents.filter((c) => c.coefficients === null); + + it.each(compounds.map((c) => ({ name: c.name, speed: c.speed })))( + "$name speed matches member sum", + ({ name, speed }) => { + const c = constituents[name]; + expect(c.members.length).toBeGreaterThan(0); + if (nameSpeedMismatch.has(name)) return; + const computed = computeSpeed(c); + const decimals = speed.toPrecision(12).replace(/0+$/, "").split(".")[1]?.length ?? 0; + expect(computed).toBeCloseTo(speed, Math.max(Math.min(decimals - 1, 6), 1)); + }, + ); + }); });