Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 78 additions & 33 deletions packages/tide-predictor/src/constituents/compound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand All @@ -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;
});
Comment on lines +172 to +185
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveSigns builds infos by calling kIndices.indexOf(i) inside tokens.map(...), which makes each combination O(tokens×K) and repeats the linear search for every K token and every kMask. Since kIndices is already in order, consider precomputing a direct mapping from token index → K-position (or iterating kIndices with a pointer) to avoid repeated indexOf work and to remove the implicit assumption that indexOf(i) can never return -1.

Copilot uses AI. Check for mistakes.
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 [
{
Expand All @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -252,6 +283,20 @@ export function decomposeCompound(
species: number,
constituents: Record<string, Constituent>,
): 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<typeof parseName>;
try {
parsed = parseName(name);
Expand Down
15 changes: 4 additions & 11 deletions packages/tide-predictor/src/constituents/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2740,13 +2740,6 @@
"nodalCorrection": "x",
"aliases": []
},
{
"name": "5MSN12",
"speed": 175.547033,
"xdo": null,
"nodalCorrection": "x",
"aliases": []
},
{
"name": "4MST12",
"speed": 175.8953503,
Expand Down
118 changes: 95 additions & 23 deletions packages/tide-predictor/test/constituents/compound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down
Loading
Loading