@@ -339,6 +339,7 @@ def from_stream(
339339 show_progress : bool = False ,
340340 batch_size : int = 128 ,
341341 phase_centre : Optional [Tuple [float , float ]] = None ,
342+ pool_noise_map : bool = False ,
342343 ) -> "Interferometer" :
343344 """
344345 Build an array-free `Interferometer` by accumulating a stream of visibility chunks,
@@ -351,6 +352,19 @@ def from_stream(
351352 which `from_sparse_terms` turns into the dataset; peak memory is set by the chunk size,
352353 not the dataset size.
353354
355+ The sparse terms assume every visibility has equal real and imaginary noise sigma: the
356+ precision operator and dirty beam are built from the real-part sigma alone. By default
357+ a chunk with unequal sigmas raises. For thermal noise a difference usually comes from
358+ the noise estimator (1-2 % when estimated by differencing adjacent visibilities); pass
359+ `pool_noise_map=True` to pool each chunk's sigmas in quadrature,
360+ `sigma^2 = (sigma_real^2 + sigma_imag^2) / 2` (which preserves the total variance),
361+ or pool them yourself before handing the chunks over with
362+ `inversion_interferometer_util.noise_map_pooled_from`. Pooling is approximate when the
363+ sigmas genuinely differ (the sparse curvature then drops the `cos(a + b)` term of the
364+ unequal weights; see `sparse_terms_from_chunks`), and the pooled `noise_normalization`
365+ differs from the unpooled one, so do not compare log-evidences of a pooled and an
366+ unpooled fit.
367+
354368 Parameters
355369 ----------
356370 chunks
@@ -367,11 +381,17 @@ def from_stream(
367381 `(y0, x0)` lands at the image origin; recorded as `sparse_terms.phase_centre`.
368382 `None` applies no shift (recorded as `(0.0, 0.0)`). See
369383 `sparse_terms_from_chunks`.
384+ pool_noise_map
385+ If `True`, pool every chunk's real and imaginary noise sigma in quadrature before
386+ forming the terms instead of raising on unequal sigmas, logging the median and
387+ maximum difference once (a warning when the median exceeds 25 %). See
388+ `sparse_terms_from_chunks`.
370389
371390 Raises
372391 ------
373392 exc.DatasetException
374- If any chunk has unequal real and imaginary noise sigma.
393+ If any chunk has unequal real and imaginary noise sigma and `pool_noise_map` is
394+ `False`.
375395 """
376396 if disable_jax ():
377397 use_jax = False
@@ -387,6 +407,7 @@ def from_stream(
387407 use_jax = use_jax ,
388408 show_progress = show_progress ,
389409 phase_centre = phase_centre ,
410+ pool_noise_map = pool_noise_map ,
390411 )
391412
392413 return cls .from_sparse_terms (
@@ -427,6 +448,7 @@ def apply_sparse_operator(
427448 show_progress : bool = False ,
428449 show_memory : bool = False ,
429450 use_jax : bool = False ,
451+ pool_noise_map : bool = False ,
430452 ):
431453 """
432454 Precompute the NUFFT precision operator for efficient pixelized source reconstruction.
@@ -493,6 +515,11 @@ def apply_sparse_operator(
493515 the harness pays 2.3-3.2 s of compile for a backend it asked to disable. (The
494516 same switch demotes the `"nufft"` builder to the NumPy brute force inside
495517 `nufft_precision_operator_from`.)
518+ pool_noise_map
519+ If `True`, pool the real and imaginary noise sigma of every visibility in
520+ quadrature, `sigma^2 = (sigma_real^2 + sigma_imag^2) / 2`, before building the
521+ operator, instead of raising on unequal sigmas (see "Precondition" below). The
522+ returned dataset carries the pooled `noise_map`.
496523
497524 Precondition
498525 ------------
@@ -502,19 +529,39 @@ def apply_sparse_operator(
502529 `psf_precision_operator_from`, which passes `noise_map_real` to
503530 `nufft_precision_operator_from`), a reduction that is exact only under that
504531 equality. With unequal sigmas the sparse curvature matrix silently disagrees with
505- the dense `InversionInterferometerMapping` path, so this method raises a
532+ the dense `InversionInterferometerMapping` path, so by default this method raises a
506533 `DatasetException` rather than returning a wrong operator.
507534
535+ For thermal noise the real and imaginary parts of one visibility have the same
536+ variance, so a measured difference usually comes from the noise estimator (1-2 % when
537+ the noise is estimated by differencing adjacent visibilities). `pool_noise_map=True`
538+ pools the two in quadrature (which preserves the total variance) and logs the median
539+ and maximum difference once: an info line up to a 25 % median difference, a warning
540+ above it, where the difference may be real. The equivalent by hand is building the
541+ dataset with `noise_map=inversion_interferometer_util.noise_map_pooled_from(noise_map)`.
542+
543+ Pooling is approximate when the sigmas genuinely differ: with per-visibility weights
544+ `w_r`, `w_i` the exact curvature is `sum wbar cos(a - b) + dw cos(a + b)`
545+ (`wbar = (w_r + w_i) / 2`, `dw = (w_r - w_i) / 2`) and `W~` holds only the
546+ `cos(a - b)` part, so the dense `InversionInterferometerMapping` path (no
547+ `apply_sparse_operator`) remains the exact one. The returned dataset carries the pooled
548+ `noise_map`, so the dense residual and chi-squared maps and the cached sparse
549+ `data_term` / `noise_normalization` describe the same noise model. The pooled
550+ `noise_normalization` differs from the unpooled one, so do not compare the
551+ log-evidences of a pooled and an unpooled fit of the same data.
552+
508553 Returns
509554 -------
510555 Interferometer
511556 A new `Interferometer` dataset with the precomputed `InterferometerSparseOperator` attached,
512557 enabling efficient pixelized source reconstruction via the sparse linear algebra formalism.
558+ With `pool_noise_map=True` its `noise_map` is the pooled one.
513559
514560 Raises
515561 ------
516562 exc.DatasetException
517- If any visibility has unequal real and imaginary noise sigma.
563+ If any visibility has unequal real and imaginary noise sigma and `pool_noise_map`
564+ is `False`.
518565 """
519566
520567 # `use_jax` now only selects between the two brute forces (the `"nufft"` builder
@@ -527,6 +574,41 @@ def apply_sparse_operator(
527574 "apply_sparse_operator" , "data" , "noise_map" , "uv_wavelengths" , "transformer"
528575 )
529576
577+ if pool_noise_map :
578+ # Every term is built from the pooled dataset, and it is the pooled dataset that is
579+ # returned, so the dense residual / chi-squared maps and the cached sparse
580+ # `data_term` / `noise_normalization` describe the same noise model.
581+ noise_map = np .asarray (self .noise_map .array , dtype = np .complex128 )
582+
583+ pooling_record = inversion_interferometer_util ._NoiseMapPoolingRecord ()
584+ pooling_record .add (noise_map )
585+
586+ dataset_pooled = Interferometer (
587+ real_space_mask = self .real_space_mask ,
588+ data = self .data ,
589+ noise_map = VisibilitiesNoiseMap (
590+ visibilities = inversion_interferometer_util .noise_map_pooled_from (
591+ noise_map
592+ )
593+ ),
594+ uv_wavelengths = self .uv_wavelengths ,
595+ transformer_class = lambda uv_wavelengths , real_space_mask : self .transformer ,
596+ )
597+
598+ pooling_record .log ()
599+
600+ return dataset_pooled .apply_sparse_operator (
601+ nufft_precision_operator = nufft_precision_operator ,
602+ batch_size = batch_size ,
603+ method = method ,
604+ eps = eps ,
605+ nufft_chunk_size = nufft_chunk_size ,
606+ chunk_k = chunk_k ,
607+ show_progress = show_progress ,
608+ show_memory = show_memory ,
609+ use_jax = use_jax ,
610+ )
611+
530612 inversion_interferometer_util .check_noise_map_real_imag_equal (self .noise_map )
531613
532614 if nufft_precision_operator is None :
@@ -639,9 +721,15 @@ def apply_sparse_operator_from_chunks(
639721 differ per chunk: `uv_wavelengths` a real `(K, 2)` array in wavelengths, `data` and
640722 `noise_map` a `Visibilities`, a complex `(K,)` array or a real `(K, 2)` array of
641723 (real, imag) columns. Every chunk must have equal real and imaginary noise sigma
642- (checked per chunk). The chunks must together be exactly this dataset's visibilities
643- (in any order) for the returned dataset to be self-consistent -- this method does not
644- check that.
724+ (checked per chunk), the assumption the sparse operator rests on (see
725+ `apply_sparse_operator`, "Precondition"). `pool_noise_map=True` is refused here: the
726+ returned dataset retains this dataset's unpooled `noise_map`, which would then describe
727+ a different noise model from the operator. Pool the dataset itself first (build it
728+ with `noise_map=inversion_interferometer_util.noise_map_pooled_from(noise_map)` and
729+ take the chunks from it), or use `Interferometer.from_stream(..., pool_noise_map=True)`
730+ (array-free, no retained noise-map). The chunks must together be exactly this
731+ dataset's visibilities (in any order) for the returned dataset to be self-consistent
732+ -- this method does not check that.
645733
646734 Memory
647735 ------
@@ -681,10 +769,11 @@ def apply_sparse_operator_from_chunks(
681769 `chunk_size`, `chunk_k`, `use_jax`, `show_progress`). `phase_centre` is rejected:
682770 it would shift the operator's terms but not this dataset's retained `data`, so the
683771 two would describe different phase centres; use `Interferometer.from_stream`
684- (array-free, no retained data) to stream with a phase-centre shift. When not given,
685- `transformer_class`, `eps` and `chunk_size` follow this dataset's transformer (the
686- same defaults `psf_precision_operator_from` takes), so the accumulated terms match
687- `apply_sparse_operator()`.
772+ (array-free, no retained data) to stream with a phase-centre shift.
773+ `pool_noise_map=True` is rejected for the same reason (see "Chunk contract").
774+ When not given, `transformer_class`, `eps` and `chunk_size` follow this dataset's
775+ transformer (the same defaults `psf_precision_operator_from` takes), so the
776+ accumulated terms match `apply_sparse_operator()`.
688777
689778 Returns
690779 -------
@@ -695,8 +784,8 @@ def apply_sparse_operator_from_chunks(
695784 Raises
696785 ------
697786 exc.DatasetException
698- If any chunk has unequal real and imaginary noise sigma, or a `phase_centre` is
699- passed.
787+ If any chunk has unequal real and imaginary noise sigma, or a `phase_centre` or
788+ `pool_noise_map=True` is passed.
700789 """
701790 if accumulator_kwargs .get ("phase_centre" ) is not None :
702791 raise exc .DatasetException (
@@ -708,6 +797,19 @@ def apply_sparse_operator_from_chunks(
708797 "array-free dataset with no retained visibilities."
709798 )
710799
800+ if accumulator_kwargs .get ("pool_noise_map" , False ):
801+ raise exc .DatasetException (
802+ "Interferometer.apply_sparse_operator_from_chunks does not take "
803+ "`pool_noise_map=True`: it would build the sparse operator from the pooled "
804+ "noise-map but retain this dataset's unpooled `noise_map`, so the two would "
805+ "describe different noise models and give contradictory likelihoods. Pool the "
806+ "dataset first -- `Interferometer(..., noise_map="
807+ "inversion_interferometer_util.noise_map_pooled_from(noise_map))`, with the "
808+ "chunks taken from it -- or use `Interferometer.from_stream(..., "
809+ "pool_noise_map=True)`, which builds an array-free dataset with no retained "
810+ "noise-map."
811+ )
812+
711813 if disable_jax () and accumulator_kwargs .get ("use_jax" , False ):
712814 accumulator_kwargs ["use_jax" ] = False
713815
0 commit comments