Skip to content

Commit 39d11b0

Browse files
committed
Add with_rolled_lon to downscaling models
Let models re-express their grid in a seam-crossing coarse domain's longitude convention while sharing network weights: - DiffusionModel.with_rolled_lon rebuilds the model through its constructor with full_fine_coords and static_inputs rolled to match the coarse grid. The roll is anchored on the western coarse-cell edge so the fine grid stays aligned to whole coarse cells. Returns self when no roll is needed. - DenoisingMoEPredictor.with_rolled_lon rolls every expert (preserving the shared-grid invariant) and rebuilds so the sigma dispatcher is reconstructed from the rolled experts. Adds tests for no-roll passthrough, coord shifting with shared weights, idempotency, coarse-cell alignment, and rolling all MoE experts.
1 parent 6f9b835 commit 39d11b0

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

fme/downscaling/models.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
PairedBatchData,
2222
StaticInputs,
2323
adjust_fine_coord_range,
24+
coords_require_lon_roll,
25+
find_roll_anchor,
2426
load_coords_from_path,
27+
roll_latlon_coords,
2528
)
2629
from fme.downscaling.metrics_and_maths import filter_tensor_mapping, interpolate
2730
from fme.downscaling.modules.diffusion_registry import DiffusionModuleRegistrySelector
@@ -747,6 +750,58 @@ def metadata(self):
747750
else 0,
748751
)
749752

753+
def _lon_roll_amount(self, coarse_lon: torch.Tensor) -> tuple[int, float]:
754+
"""
755+
Number of positions to roll the fine grid (and the lon_start it aligns to)
756+
so the fine cells stay aligned to coarse_lon's coarse cells.
757+
758+
coarse_lon is the actual coarse domain grid, so it already carries the
759+
convention to align to. The roll is anchored on the western coarse-cell
760+
*edge* (half a coarse cell below coarse_lon.min(), which is a cell *center*)
761+
so the fine grid rolls by a whole number of coarse cells and its cells stay
762+
aligned to the coarse cells. Anchoring on the center instead would roll by an
763+
extra downscale_factor // 2 fine points, splitting the boundary coarse cell
764+
across the seam.
765+
"""
766+
lon_start = float(coarse_lon.min())
767+
fine_lon = self.full_fine_coords.lon
768+
fine_spacing = float(fine_lon[1] - fine_lon[0])
769+
western_edge = lon_start - self.downscale_factor * fine_spacing / 2.0
770+
return find_roll_anchor(fine_lon, western_edge), lon_start
771+
772+
def with_rolled_lon(self, coarse_lon: torch.Tensor) -> "DiffusionModel":
773+
"""
774+
Return a new model with full_fine_coords and static_inputs rolled to match
775+
coarse_lon's longitude convention, sharing the network weights.
776+
777+
Returns self unchanged when coarse_lon does not cross the prime meridian.
778+
The new model is built through the constructor (rather than a shallow copy)
779+
so its coords are re-validated and derived state is rebuilt fresh; the raw
780+
module is unwrapped and passed so __init__ re-wraps it exactly once.
781+
"""
782+
if not coords_require_lon_roll(coarse_lon):
783+
return self
784+
roll_amount, lon_start = self._lon_roll_amount(coarse_lon)
785+
return DiffusionModel(
786+
config=self.config,
787+
module=self.module.module,
788+
normalizer=self.normalizer,
789+
loss=self.loss,
790+
coarse_shape=self.coarse_shape,
791+
downscale_factor=self.downscale_factor,
792+
sigma_data=self.sigma_data,
793+
full_fine_coords=roll_latlon_coords(
794+
self.full_fine_coords, roll_amount, lon_start
795+
),
796+
in_names=self.in_names,
797+
out_names=self.out_names,
798+
static_inputs=(
799+
self.static_inputs.roll(roll_amount, lon_start)
800+
if self.static_inputs is not None
801+
else None
802+
),
803+
)
804+
750805

751806
@dataclasses.dataclass
752807
class _CheckpointModelConfigSelector:

fme/downscaling/predictors/serial_denoising.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,26 @@ def static_inputs(self) -> StaticInputs | None:
226226
def get_fine_coords_for_batch(self, batch: BatchData) -> LatLonCoordinates:
227227
return self._primary.get_fine_coords_for_batch(batch)
228228

229+
def with_rolled_lon(self, coarse_lon: torch.Tensor) -> "DenoisingMoEPredictor":
230+
"""New predictor with every expert's coords rolled to match coarse_lon.
231+
232+
All experts are rolled (not just the primary) so the shared-grid invariant
233+
enforced in __init__ still holds -- nothing relies on the non-primary
234+
experts' coordinates being left unrolled. Rebuilt through __init__ so
235+
_dispatch_module is reconstructed from the rolled experts. Returns self
236+
unchanged when no roll is needed.
237+
"""
238+
rolled = [expert.with_rolled_lon(coarse_lon) for expert in self._experts]
239+
if all(r is e for r, e in zip(rolled, self._experts)):
240+
return self
241+
return DenoisingMoEPredictor(
242+
experts=rolled,
243+
sigma_ranges=self._sigma_ranges,
244+
num_diffusion_generation_steps=self._num_diffusion_generation_steps,
245+
churn=self._churn,
246+
expert_renames=self._expert_renames,
247+
)
248+
229249
@torch.no_grad()
230250
def generate(
231251
self,

fme/downscaling/test_models.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
535738
def test_checkpoint_config_topography_raises():
536739
with pytest.raises(ValueError):
537740
CheckpointModelConfig(

0 commit comments

Comments
 (0)