Skip to content

Commit 8518a94

Browse files
committed
fixes for compound constituent name parser, correct 2 naming constituent naming errors
1 parent 12b3823 commit 8518a94

4 files changed

Lines changed: 181 additions & 78 deletions

File tree

packages/tide-predictor/src/constituents/compound.ts

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,15 @@ const K2_INFO: LetterInfo = { species: 2 };
5858
* Throws for names that cannot be decomposed — any constituent with nodal
5959
* correction code "x" must have a parseable compound name.
6060
*
61-
* IHO Annex B exception: MA and MB constituents are annual variants that
62-
* follow the same decomposition as their base M constituent.
61+
* Note: MA/MB annual variants are handled by decomposeCompound before
62+
* reaching parseName, so they never enter this function.
6363
*/
6464
export function parseName(name: string): { tokens: ParsedToken[]; targetSpecies: number } {
6565
const fail = (reason: string): Error =>
6666
new Error(`Unable to parse compound constituent "${name}": ${reason}`);
6767

68-
// IHO Annex B exception: Normalize MA/MB annual variants to M
69-
let normalizedName = name;
70-
if ((name.startsWith("MA") || name.startsWith("MB")) && name.length > 2) {
71-
normalizedName = "M" + name.substring(2);
72-
}
73-
7468
// Extract trailing species number
75-
const m = normalizedName.match(/^(.+?)(\d+)$/);
69+
const m = name.match(/^(.+?)(\d+)$/);
7670
if (!m) throw fail("no trailing species digits");
7771

7872
const body = m[1];
@@ -147,6 +141,15 @@ function isLower(ch: string): boolean {
147141
return ch >= "a" && ch <= "z";
148142
}
149143

144+
function popcount(n: number): number {
145+
let count = 0;
146+
while (n) {
147+
count += n & 1;
148+
n >>= 1;
149+
}
150+
return count;
151+
}
152+
150153
function isKnownLetter(letter: string): boolean {
151154
// A and B are not compound letters per Annex B exceptions
152155
if (letter === "A" || letter === "B") return false;
@@ -159,39 +162,45 @@ function isKnownLetter(letter: string): boolean {
159162
* Resolve component signs using the IHO Annex B progressive right-to-left
160163
* sign-flipping algorithm.
161164
*
162-
* For K (ambiguous between K1 and K2), tries K2 first then K1.
165+
* For K (ambiguous between K1 and K2), tries all 2^N combinations of K1/K2
166+
* per K token, starting with all-K2 (most common in even-species compounds).
163167
*/
164168
export function resolveSigns(
165169
tokens: ParsedToken[],
166170
targetSpecies: number,
167171
): ResolvedComponent[] | null {
168-
const hasK = tokens.some((t) => t.letter === "K");
172+
const kIndices: number[] = [];
173+
for (let i = 0; i < tokens.length; i++) {
174+
if (tokens[i].letter === "K") kIndices.push(i);
175+
}
169176

170-
if (hasK) {
171-
// Try K2 first (more common in even-species compounds)
172-
const result = tryResolve(tokens, targetSpecies, K2_INFO);
177+
const nK = kIndices.length;
178+
const nCombinations = nK > 0 ? 1 << nK : 1;
179+
180+
for (let kMask = 0; kMask < nCombinations; kMask++) {
181+
const infos = tokens.map((t, i) => {
182+
if (t.letter !== "K") return LETTER_MAP[t.letter];
183+
const ki = kIndices.indexOf(i);
184+
return kMask & (1 << ki) ? K1_INFO : K2_INFO;
185+
});
186+
const result = tryResolve(tokens, targetSpecies, infos);
173187
if (result) return result;
174-
// Fall back to K1
175-
return tryResolve(tokens, targetSpecies, K1_INFO);
176188
}
177189

178-
return tryResolve(tokens, targetSpecies, K2_INFO);
190+
return null;
179191
}
180192

181193
function tryResolve(
182194
tokens: ParsedToken[],
183195
targetSpecies: number,
184-
kInfo: LetterInfo,
196+
infos: LetterInfo[],
185197
): ResolvedComponent[] | null {
186-
const infos = tokens.map((t) => (t.letter === "K" ? kInfo : LETTER_MAP[t.letter]));
187-
188198
/** Derive constituent key: letter + species (e.g. "M2", "S2", "K1") */
189199
const keyOf = (j: number) => tokens[j].letter + infos[j].species;
190200

191-
// Single-letter overtide: e.g. M4 = M2 × M2
201+
// Single-letter overtide: e.g. M4 = 2×M2, M6 = 3×M2
192202
if (tokens.length === 1) {
193-
const info = infos[0];
194-
const letterSpecies = info.species;
203+
const letterSpecies = infos[0].species;
195204
if (letterSpecies > 0 && targetSpecies > letterSpecies) {
196205
return [
197206
{
@@ -200,15 +209,6 @@ function tryResolve(
200209
},
201210
];
202211
}
203-
// Single letter, species matches directly (shouldn't normally be "x" code)
204-
if (letterSpecies === targetSpecies) {
205-
return [
206-
{
207-
constituentKey: keyOf(0),
208-
factor: 1,
209-
},
210-
];
211-
}
212212
}
213213

214214
// Progressive right-to-left sign flip (IHO Annex B)
@@ -224,7 +224,38 @@ function tryResolve(
224224
total -= 2 * tokens[j].multiplier * infos[j].species;
225225
}
226226

227-
if (total !== targetSpecies) return null;
227+
if (total !== targetSpecies) {
228+
// Brute-force fallback: try all 2^N sign combinations.
229+
// Handles non-contiguous patterns like [+, -, +] that the
230+
// right-to-left heuristic misses. Collect all valid combinations
231+
// and prefer fewest negatives, with negatives on later tokens
232+
// (matching the IHO convention where leading letters are positive).
233+
const n = tokens.length;
234+
const valid: number[] = [];
235+
for (let mask = 0; mask < 1 << n; mask++) {
236+
let sum = 0;
237+
for (let j = 0; j < n; j++) {
238+
const sign = mask & (1 << j) ? -1 : 1;
239+
sum += sign * tokens[j].multiplier * infos[j].species;
240+
}
241+
if (sum === targetSpecies) valid.push(mask);
242+
}
243+
if (valid.length > 0) {
244+
valid.sort((a, b) => {
245+
const popA = popcount(a);
246+
const popB = popcount(b);
247+
if (popA !== popB) return popA - popB;
248+
// Among same popcount, prefer negatives on later tokens (higher bits)
249+
return b - a;
250+
});
251+
const mask = valid[0];
252+
return tokens.map((t, j) => ({
253+
constituentKey: keyOf(j),
254+
factor: ((mask & (1 << j) ? -1 : 1) as 1 | -1) * t.multiplier,
255+
}));
256+
}
257+
return null;
258+
}
228259

229260
return tokens.map((t, j) => ({
230261
constituentKey: keyOf(j),
@@ -252,6 +283,20 @@ export function decomposeCompound(
252283
species: number,
253284
constituents: Record<string, Constituent>,
254285
): ConstituentMember[] | null {
286+
// MA/MB annual variants: overtide of M2 with annual modulation (±Sa).
287+
// MA{n} = (n/2)×M2 − Sa, MB{n} = (n/2)×M2 + Sa.
288+
const maMatch = name.match(/^M([AB])(\d+)$/);
289+
if (maMatch) {
290+
const [, variant, speciesStr] = maMatch;
291+
const m2 = constituents.M2;
292+
const sa = constituents.Sa;
293+
if (!m2 || !sa) return null;
294+
return [
295+
{ constituent: m2, factor: parseInt(speciesStr, 10) / 2 },
296+
{ constituent: sa, factor: variant === "A" ? -1 : 1 },
297+
];
298+
}
299+
255300
let parsed: ReturnType<typeof parseName>;
256301
try {
257302
parsed = parseName(name);

packages/tide-predictor/src/constituents/data.json

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2689,14 +2689,7 @@
26892689
"speed": 174.448999822,
26902690
"xdo": [12, 6, 5, 4, 5, 5, 7],
26912691
"nodalCorrection": "x",
2692-
"aliases": []
2693-
},
2694-
{
2695-
"name": "4ML12",
2696-
"speed": 174.449,
2697-
"xdo": null,
2698-
"nodalCorrection": "x",
2699-
"aliases": []
2692+
"aliases": ["4ML12"]
27002693
},
27012694
{
27022695
"name": "4MNK12",
@@ -2754,13 +2747,6 @@
27542747
"nodalCorrection": "x",
27552748
"aliases": []
27562749
},
2757-
{
2758-
"name": "5MSN12",
2759-
"speed": 175.547033,
2760-
"xdo": null,
2761-
"nodalCorrection": "x",
2762-
"aliases": []
2763-
},
27642750
{
27652751
"name": "4MST12",
27662752
"speed": 175.89535001125,

packages/tide-predictor/test/constituents/compound.test.ts

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -118,23 +118,11 @@ describe("parseName", () => {
118118
});
119119
});
120120

121-
it("parses MA/MB annual variants (IHO Annex B)", () => {
122-
// MA4 is normalized to M4 before parsing
123-
expect(parseName("MA4")).toEqual({
124-
tokens: [{ letter: "M", multiplier: 1 }],
125-
targetSpecies: 4,
126-
});
127-
// MB5 is normalized to M5 before parsing
128-
expect(parseName("MB5")).toEqual({
129-
tokens: [{ letter: "M", multiplier: 1 }],
130-
targetSpecies: 5,
131-
});
132-
});
133-
134121
it("throws for unknown letter outside parenthesized group", () => {
135-
// A and B are only valid as part of MA/MB pattern
122+
// A and B are not compound letters (MA/MB handled by decomposeCompound)
136123
expect(() => parseName("A4")).toThrow('unknown letter "A"');
137124
expect(() => parseName("B5")).toThrow('unknown letter "B"');
125+
expect(() => parseName("MA4")).toThrow('unknown letter "A"');
138126
expect(() => parseName("SA4")).toThrow('unknown letter "A"');
139127
});
140128

@@ -460,35 +448,43 @@ describe("decomposeCompound", () => {
460448
expect(result![1].factor).toBe(1);
461449
});
462450

463-
it("decomposes MA annual variants (IHO Annex B)", () => {
464-
// MA4 should decompose as M4 (= M2 × M2)
451+
it("decomposes MA annual variants as (n/2)×M2 - Sa (IHO Annex B)", () => {
452+
// MA4 = 2×M2 - Sa
465453
const ma4 = decomposeCompound("MA4", 4, constituents);
466454
expect(ma4).not.toBeNull();
467-
expect(ma4).toHaveLength(1);
455+
expect(ma4).toHaveLength(2);
468456
expect(ma4![0].constituent).toBe(constituents.M2);
469457
expect(ma4![0].factor).toBe(2);
458+
expect(ma4![1].constituent).toBe(constituents.Sa);
459+
expect(ma4![1].factor).toBe(-1);
470460

471-
// MA6 should decompose as M6 (= M2 × M2 × M2)
461+
// MA6 = 3×M2 - Sa
472462
const ma6 = decomposeCompound("MA6", 6, constituents);
473463
expect(ma6).not.toBeNull();
474-
expect(ma6).toHaveLength(1);
464+
expect(ma6).toHaveLength(2);
475465
expect(ma6![0].factor).toBe(3);
466+
expect(ma6![1].constituent).toBe(constituents.Sa);
467+
expect(ma6![1].factor).toBe(-1);
476468
});
477469

478470
it("decomposes MB/MA annual variants with fractional factors", () => {
479-
// MB5 normalizes to M5 = M2 × 2.5
471+
// MB5 = 2.5×M2 + Sa
480472
const mb5 = decomposeCompound("MB5", 5, constituents);
481473
expect(mb5).not.toBeNull();
482-
expect(mb5).toHaveLength(1);
474+
expect(mb5).toHaveLength(2);
483475
expect(mb5![0].constituent).toBe(constituents.M2);
484476
expect(mb5![0].factor).toBe(2.5);
477+
expect(mb5![1].constituent).toBe(constituents.Sa);
478+
expect(mb5![1].factor).toBe(1);
485479

486-
// MA9 normalizes to M9 = M2 × 4.5
480+
// MA9 = 4.5×M2 - Sa
487481
const ma9 = decomposeCompound("MA9", 9, constituents);
488482
expect(ma9).not.toBeNull();
489-
expect(ma9).toHaveLength(1);
483+
expect(ma9).toHaveLength(2);
490484
expect(ma9![0].constituent).toBe(constituents.M2);
491485
expect(ma9![0].factor).toBe(4.5);
486+
expect(ma9![1].constituent).toBe(constituents.Sa);
487+
expect(ma9![1].factor).toBe(-1);
492488
});
493489

494490
it("returns null for unparseable names", () => {
@@ -532,6 +528,82 @@ describe("decomposeCompound", () => {
532528
expect(oq2![0].constituent).toBe(constituents.O1);
533529
expect(oq2![1].constituent).toBe(constituents.Q1);
534530
});
531+
532+
function memberSpeed(members: { constituent: { speed: number }; factor: number }[]) {
533+
return members.reduce((sum, m) => sum + m.factor * m.constituent.speed, 0);
534+
}
535+
536+
// Sign pattern [+3, -3, +1] not reachable by right-to-left flip algorithm.
537+
// Doodson: (2, 5, -6, 1, 0, 0) → 3×S2 - 3×M2 + N2
538+
it("3(SM)N2 = 3×S2 - 3×M2 + N2", () => {
539+
const result = decomposeCompound("3(SM)N2", 0, constituents);
540+
expect(result).not.toBeNull();
541+
expect(result).toHaveLength(3);
542+
expect(memberSpeed(result!)).toBeCloseTo(constituents["3(SM)N2"].speed, 6);
543+
});
544+
545+
// Both K tokens must resolve independently: first K→K1, second K→K2.
546+
// Doodson: (5, 5, -2, 0, 0, 0) → S2 + K1 + K2
547+
it("(SK)K5 = S2 + K1 + K2", () => {
548+
const result = decomposeCompound("(SK)K5", 0, constituents);
549+
expect(result).not.toBeNull();
550+
expect(result).toHaveLength(3);
551+
expect(result![0].constituent).toBe(constituents.S2);
552+
expect(result![1].constituent).toBe(constituents.K1);
553+
expect(result![2].constituent).toBe(constituents.K2);
554+
expect(memberSpeed(result!)).toBeCloseTo(constituents["(SK)K5"].speed, 6);
555+
});
556+
557+
// IHO name "4ML12" is a naming error — should be 5ML12 (5×M2 + L2).
558+
// TideHarmonics uses the corrected name. 4ML12 is kept as an alias.
559+
// Name now decomposes correctly: 5×M + L = 10+2 = 12.
560+
it("5ML12 = 5×M2 + L2 (IHO name corrected from 4ML12)", () => {
561+
const c = constituents["5ML12"];
562+
expect(c.members).toHaveLength(2);
563+
expect(c.members[0].constituent).toBe(constituents.M2);
564+
expect(c.members[0].factor).toBe(5);
565+
expect(c.members[1].constituent).toBe(constituents.L2);
566+
expect(c.members[1].factor).toBe(1);
567+
expect(memberSpeed(c.members)).toBeCloseTo(c.speed, 6);
568+
// Old IHO name still accessible via alias
569+
expect(constituents["4ML12"]).toBe(c);
570+
});
571+
572+
// IHO "5MSN12" is a naming error — Doodson (12,3,0,-1) has h=0, ruling
573+
// out S2 (h=-2). The real composition is 6×M2 + Mfm. TideHarmonics
574+
// omits this entry entirely. We drop it too (6MSN12 is the valid 12th-
575+
// diurnal M+S-N compound).
576+
it("5MSN12 is dropped (naming error in IHO list)", () => {
577+
expect(constituents["5MSN12"]).toBeUndefined();
578+
});
579+
580+
// Per IHO Annex B: 3×N2 + 2×M2 + S2, all positive (species 6+4+2=12).
581+
// The stored speed (173.362) differs from the member sum (173.287) by
582+
// 0.075°/hr — a data discrepancy, not a parser issue.
583+
it("3N2MS12 = 3×N2 + 2×M2 + S2 (IHO Annex B)", () => {
584+
const result = decomposeCompound("3N2MS12", 0, constituents);
585+
expect(result).not.toBeNull();
586+
expect(result).toHaveLength(3);
587+
expect(result![0].constituent).toBe(constituents.N2);
588+
expect(result![0].factor).toBe(3);
589+
expect(result![1].constituent).toBe(constituents.M2);
590+
expect(result![1].factor).toBe(2);
591+
expect(result![2].constituent).toBe(constituents.S2);
592+
expect(result![2].factor).toBe(1);
593+
});
594+
595+
// MA normalization strips "A" → "M12" → 6×M2, but MA12 is actually
596+
// the annual variant: 6×M2 - Sa. Doodson differs in h coefficient.
597+
it("MA12 = 6×M2 - Sa (annual modulation)", () => {
598+
const result = decomposeCompound("MA12", 0, constituents);
599+
expect(result).not.toBeNull();
600+
expect(result).toHaveLength(2);
601+
expect(result![0].constituent).toBe(constituents.M2);
602+
expect(result![0].factor).toBe(6);
603+
expect(result![1].constituent).toBe(constituents.Sa);
604+
expect(result![1].factor).toBe(-1);
605+
expect(memberSpeed(result!)).toBeCloseTo(constituents.MA12.speed, 6);
606+
});
535607
});
536608

537609
// ─── All "x" constituents ───────────────────────────────────────────────────

packages/tide-predictor/test/constituents/index.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,19 +286,19 @@ describe("Base constituent definitions", () => {
286286
});
287287

288288
describe("speed derived from compound members", () => {
289-
// These compounds have known issues in the compound name parser:
290-
// - 3(SM)N2, (SK)K5, 4ML12, 5MSN12: sign resolution can't reach target species
291-
// - 3N2MS12, MA12: parser produces incorrect decomposition
292-
const parserBugs = new Set(["3(SM)N2", "(SK)K5", "4ML12", "5MSN12", "3N2MS12", "MA12"]);
293-
const compounds = allConstituents.filter(
294-
(c) => c.coefficients === null && !parserBugs.has(c.name),
295-
);
289+
// These constituents have IHO Doodson numbers that don't match
290+
// their IHO Annex B name decomposition, so the member-derived
291+
// speed diverges from the stored speed.
292+
const nameSpeedMismatch = new Set(["3N2MS12"]);
293+
294+
const compounds = allConstituents.filter((c) => c.coefficients === null);
296295

297296
it.each(compounds.map((c) => ({ name: c.name, speed: c.speed })))(
298297
"$name speed matches member sum",
299298
({ name, speed }) => {
300299
const c = constituents[name];
301300
expect(c.members.length).toBeGreaterThan(0);
301+
if (nameSpeedMismatch.has(name)) return;
302302
const computed = computeSpeed(c);
303303
const decimals = speed.toPrecision(12).replace(/0+$/, "").split(".")[1]?.length ?? 0;
304304
expect(computed).toBeCloseTo(speed, Math.max(Math.min(decimals - 1, 6), 1));

0 commit comments

Comments
 (0)