@@ -532,6 +532,209 @@ def test_get_fine_coords_for_batch():
532532 assert torch .allclose (result .lon , expected_lon )
533533
534534
535+ def _make_global_fine_coords_and_static (fine_shape : tuple [int , int ]):
536+ """Return a global-covering LatLonCoordinates and matching StaticInputs."""
537+ step = 360 / fine_shape [1 ]
538+ global_fine_lon = torch .arange (fine_shape [1 ]) * step + step / 2
539+ global_fine_lat = _get_monotonic_coordinate (fine_shape [0 ], stop = fine_shape [0 ])
540+ full_fine_coords = LatLonCoordinates (lat = global_fine_lat , lon = global_fine_lon )
541+ static_field = torch .arange (
542+ fine_shape [0 ] * fine_shape [1 ], dtype = torch .float32
543+ ).reshape (* fine_shape )
544+ static_inputs = StaticInputs (
545+ fields = [StaticInput (static_field )], coords = full_fine_coords
546+ )
547+ return full_fine_coords , static_inputs
548+
549+
550+ def test_with_rolled_lon_no_roll_returns_same ():
551+ """with_rolled_lon returns the original model when no roll is needed."""
552+ coarse_shape = (8 , 16 )
553+ fine_shape = (16 , 32 )
554+ static_inputs = make_static_inputs (fine_shape )
555+ model = _get_diffusion_model (
556+ coarse_shape = coarse_shape ,
557+ downscale_factor = 2 ,
558+ full_fine_coords = static_inputs .coords ,
559+ static_inputs = static_inputs ,
560+ )
561+ coarse_lon = _get_monotonic_coordinate (coarse_shape [1 ], stop = fine_shape [1 ])
562+ assert model .with_rolled_lon (coarse_lon ) is model
563+
564+
565+ def test_with_rolled_lon_shifts_coords_and_shares_weights ():
566+ """with_rolled_lon: new model with rolled coords, shared network weights."""
567+ coarse_shape = (8 , 16 )
568+ fine_shape = (16 , 32 )
569+ full_fine_coords , static_inputs = _make_global_fine_coords_and_static (fine_shape )
570+ model = _get_diffusion_model (
571+ coarse_shape = coarse_shape ,
572+ downscale_factor = 2 ,
573+ full_fine_coords = full_fine_coords ,
574+ static_inputs = static_inputs ,
575+ )
576+
577+ coarse_lon = torch .tensor ([- 10.0 , - 5.0 , 0.0 , 5.0 ], dtype = torch .float32 )
578+ rolled = model .with_rolled_lon (coarse_lon )
579+
580+ # Reconstruction wraps a fresh module around the SAME raw weights.
581+ assert rolled .module is not model .module
582+ assert next (rolled .module .parameters ()) is next (model .module .parameters ())
583+ assert not torch .equal (rolled .full_fine_coords .lon , model .full_fine_coords .lon )
584+ assert torch .all (rolled .full_fine_coords .lon [1 :] > rolled .full_fine_coords .lon [:- 1 ])
585+ assert rolled .full_fine_coords .lon [0 ].item () < 0
586+ assert rolled .static_inputs is not None
587+ # Compare against model.static_inputs (on-device) rather than the CPU-side original
588+ assert not torch .equal (
589+ rolled .static_inputs .fields [0 ].data , model .static_inputs .fields [0 ].data
590+ )
591+
592+
593+ def test_with_rolled_lon_is_idempotent ():
594+ """Rolling an already-rolled model with the same domain is a no-op.
595+
596+ Guards against accidental double-rolling: the second roll resolves to 0
597+ (full rotation), so the twice-rolled model has identical coords and static
598+ inputs to the once-rolled one.
599+ """
600+ coarse_shape = (8 , 16 )
601+ fine_shape = (16 , 32 )
602+ full_fine_coords , static_inputs = _make_global_fine_coords_and_static (fine_shape )
603+ model = _get_diffusion_model (
604+ coarse_shape = coarse_shape ,
605+ downscale_factor = 2 ,
606+ full_fine_coords = full_fine_coords ,
607+ static_inputs = static_inputs ,
608+ )
609+
610+ coarse_lon = torch .tensor ([- 10.0 , - 5.0 , 0.0 , 5.0 ], dtype = torch .float32 )
611+ rolled = model .with_rolled_lon (coarse_lon )
612+ twice = rolled .with_rolled_lon (coarse_lon )
613+
614+ assert torch .equal (twice .full_fine_coords .lon , rolled .full_fine_coords .lon )
615+ assert rolled .static_inputs is not None and twice .static_inputs is not None
616+ assert torch .equal (
617+ twice .static_inputs .fields [0 ].data , rolled .static_inputs .fields [0 ].data
618+ )
619+
620+
621+ def test_roll_diffusion_model_keeps_fine_aligned_to_coarse_cells ():
622+ """A seam-crossing domain must roll the fine grid by whole coarse cells.
623+
624+ The roll is anchored on the western coarse-cell edge, not its center. If it
625+ anchored on the center it would roll an extra downscale_factor // 2 fine
626+ points, leaving no fine margin below the western coarse cell -- which makes
627+ get_fine_coords_for_batch raise -- and splitting that cell across the seam.
628+ """
629+ coarse_shape = (4 , 8 )
630+ fine_shape = (16 , 32 )
631+ factor = 4
632+ full_fine_coords , static_inputs = _make_global_fine_coords_and_static (fine_shape )
633+ model = _get_diffusion_model (
634+ coarse_shape = coarse_shape ,
635+ downscale_factor = factor ,
636+ full_fine_coords = full_fine_coords ,
637+ static_inputs = static_inputs ,
638+ )
639+
640+ # Four of the eight global 45-degree coarse cells, crossing the 0/360 seam and
641+ # expressed in negative convention (physically 292.5, 337.5 and 22.5, 67.5).
642+ # Coarse-lat centers [6, 10] are interior, leaving fine margin above and below.
643+ coarse_lat = [6.0 , 10.0 ]
644+ coarse_lon = [- 67.5 , - 22.5 , 22.5 , 67.5 ]
645+ batch = make_batch_data (
646+ (1 , len (coarse_lat ), len (coarse_lon )), coarse_lat , coarse_lon
647+ )
648+
649+ rolled = model .with_rolled_lon (torch .tensor (coarse_lon , dtype = torch .float32 ))
650+ # Anchoring on the cell center would leave no margin and raise here.
651+ fine_coords = rolled .get_fine_coords_for_batch (batch )
652+
653+ # Each coarse cell is covered by exactly `factor` fine cells whose mean is the
654+ # coarse-cell center -- i.e. the fine grid stayed aligned to the coarse cells.
655+ recentered = fine_coords .lon .reshape (len (coarse_lon ), factor ).mean (dim = 1 ).cpu ()
656+ assert torch .allclose (recentered , torch .tensor (coarse_lon ), atol = 1e-3 )
657+
658+
659+ def test_denoising_moe_predictor_with_rolled_lon_rolls_all_experts ():
660+ """with_rolled_lon rolls every expert (keeping the shared-grid invariant)."""
661+ from fme .downscaling .predictors .serial_denoising import DenoisingMoEPredictor
662+
663+ coarse_shape = (8 , 16 )
664+ fine_shape = (16 , 32 )
665+ full_fine_coords , static_inputs = _make_global_fine_coords_and_static (fine_shape )
666+
667+ expert0 = _get_diffusion_model (
668+ coarse_shape = coarse_shape ,
669+ downscale_factor = 2 ,
670+ full_fine_coords = full_fine_coords ,
671+ static_inputs = static_inputs ,
672+ )
673+ expert1 = _get_diffusion_model (
674+ coarse_shape = coarse_shape ,
675+ downscale_factor = 2 ,
676+ full_fine_coords = full_fine_coords ,
677+ static_inputs = static_inputs ,
678+ )
679+ predictor = DenoisingMoEPredictor (
680+ experts = [expert0 , expert1 ],
681+ sigma_ranges = [(0.0 , 0.5 ), (0.5 , 1.0 )],
682+ num_diffusion_generation_steps = 2 ,
683+ churn = 0.0 ,
684+ )
685+
686+ coarse_lon = torch .tensor ([- 10.0 , - 5.0 , 0.0 , 5.0 ], dtype = torch .float32 )
687+ rolled = predictor .with_rolled_lon (coarse_lon )
688+
689+ # Every expert is a new (rolled) object; _primary stays _experts[0].
690+ assert rolled ._primary is rolled ._experts [0 ]
691+ for rolled_expert , original , source in zip (
692+ rolled ._experts , predictor ._experts , [expert0 , expert1 ]
693+ ):
694+ assert rolled_expert is not original
695+ # Coords are rolled...
696+ assert rolled_expert .full_fine_coords .lon [0 ].item () < 0
697+ # ...but the raw network weights are still shared (fresh wrapper).
698+ assert next (rolled_expert .module .parameters ()) is next (
699+ source .module .parameters ()
700+ )
701+ # The sigma dispatcher is rebuilt from the rolled experts, consistent with
702+ # _experts (not left pointing at any pre-roll module).
703+ for entry , rolled_expert in zip (rolled ._dispatch_module ._entries , rolled ._experts ):
704+ assert entry [2 ] is rolled_expert .module
705+
706+ # No-roll case returns self
707+ non_neg_lon = torch .tensor ([0.0 , 5.0 , 10.0 , 15.0 ], dtype = torch .float32 )
708+ assert predictor .with_rolled_lon (non_neg_lon ) is predictor
709+
710+
711+ def test_denoising_moe_predictor_rejects_mismatched_expert_grids ():
712+ """Experts on different grids are rejected at construction (shared-grid)."""
713+ from fme .downscaling .predictors .serial_denoising import DenoisingMoEPredictor
714+
715+ fine_coords_a , static_a = _make_global_fine_coords_and_static ((16 , 32 ))
716+ fine_coords_b , static_b = _make_global_fine_coords_and_static ((16 , 16 ))
717+ expert_a = _get_diffusion_model (
718+ coarse_shape = (8 , 16 ),
719+ downscale_factor = 2 ,
720+ full_fine_coords = fine_coords_a ,
721+ static_inputs = static_a ,
722+ )
723+ expert_b = _get_diffusion_model (
724+ coarse_shape = (8 , 8 ),
725+ downscale_factor = 2 ,
726+ full_fine_coords = fine_coords_b ,
727+ static_inputs = static_b ,
728+ )
729+ with pytest .raises (ValueError , match = "metadata" ):
730+ DenoisingMoEPredictor (
731+ experts = [expert_a , expert_b ],
732+ sigma_ranges = [(0.0 , 0.5 ), (0.5 , 1.0 )],
733+ num_diffusion_generation_steps = 2 ,
734+ churn = 0.0 ,
735+ )
736+
737+
535738def test_checkpoint_config_topography_raises ():
536739 with pytest .raises (ValueError ):
537740 CheckpointModelConfig (
0 commit comments