@@ -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 ───────────────────────────────────────────────────
0 commit comments