@@ -790,3 +790,251 @@ def body(block_i, C):
790790 C_pad = lax .fori_loop (0 , n_blocks , body , C0 )
791791 C = C_pad [:, :S ]
792792 return 0.5 * (C + C .T )
793+
794+ def curvature_matrix_off_diag_from (
795+ self , rows0 , cols0 , vals0 , rows1 , cols1 , vals1 , * , S0 : int , S1 : int
796+ ):
797+ """
798+ Compute the off-diagonal (mapper-mapper) curvature block F01 = A0ᵀ W~ A1.
799+
800+ This method mirrors `ImagingSparseOperator.curvature_matrix_off_diag_from` and is the
801+ structural counterpart for the interferometer W~ operator. The difference between the two
802+ is the operator itself: for imaging W = Hᵀ N⁻¹ H is a PSF correlation, whereas here
803+ W~ = Re(Fᴴ W F) is the (translationally invariant) real-space operator of the non-uniform
804+ Fourier transform `F`, applied via `apply_operator` on the *unmasked-extent* rectangular
805+ grid (M = y_shape * x_shape).
806+
807+ Given two sparse mapping operators:
808+
809+ - A0 : (M × S0)
810+ - A1 : (M × S1)
811+
812+ this method computes F01 = A0ᵀ W~ A1 in column blocks of width `batch_size`:
813+
814+ 1) Assemble Fbatch = A1[:, start:start+B] on the rectangular grid via scatter-add.
815+ 2) Apply W~ to the block via FFT: Gbatch = W~(Fbatch).
816+ 3) Project back with A0ᵀ via segment_sum over `cols0`.
817+
818+ Parameters
819+ ----------
820+ rows0, cols0, vals0
821+ COO triplets for A0, where `rows0` are extent-grid (flat) indices in [0, M).
822+ rows1, cols1, vals1
823+ COO triplets for A1, where `rows1` are extent-grid (flat) indices in [0, M).
824+ S0
825+ Number of source pixels / parameters for mapper 0.
826+ S1
827+ Number of source pixels / parameters for mapper 1.
828+
829+ Returns
830+ -------
831+ ndarray
832+ Off-diagonal curvature block of shape (S0, S1).
833+
834+ Notes
835+ -----
836+ - The result is *not* symmetrized here because it is not square in general. The symmetric
837+ counterpart is F10 = F01ᵀ, because A0 and A1 share the same W~.
838+ - Padding to `S1_pad = ceil(S1/B)*B` ensures `dynamic_update_slice` is always legal.
839+ """
840+ import jax .numpy as jnp
841+ from jax import lax
842+ from jax .ops import segment_sum
843+
844+ rows0 = jnp .asarray (rows0 , dtype = jnp .int32 )
845+ cols0 = jnp .asarray (cols0 , dtype = jnp .int32 )
846+ vals0 = jnp .asarray (vals0 , dtype = jnp .float64 )
847+
848+ rows1 = jnp .asarray (rows1 , dtype = jnp .int32 )
849+ cols1 = jnp .asarray (cols1 , dtype = jnp .int32 )
850+ vals1 = jnp .asarray (vals1 , dtype = jnp .float64 )
851+
852+ M = self .M
853+ B = self .batch_size
854+
855+ n_blocks = (S1 + B - 1 ) // B
856+ S1_pad = n_blocks * B
857+
858+ F01_0 = jnp .zeros ((S0 , S1_pad ), dtype = jnp .float64 )
859+
860+ def body (block_i , F01 ):
861+ start = block_i * B
862+
863+ in_block = (cols1 >= start ) & (cols1 < (start + B ))
864+ bc = jnp .where (in_block , cols1 - start , 0 ).astype (jnp .int32 )
865+ v = jnp .where (in_block , vals1 , 0.0 )
866+
867+ F = jnp .zeros ((M , B ), dtype = jnp .float64 )
868+ F = F .at [rows1 , bc ].add (v )
869+
870+ G = self .apply_operator (F ) # (M, B)
871+
872+ contrib = vals0 [:, None ] * G [rows0 , :]
873+ block = segment_sum (contrib , cols0 , num_segments = S0 )
874+
875+ width = jnp .minimum (B , jnp .maximum (0 , S1 - start ))
876+ block = block * (self .col_offsets < width )[None , :]
877+
878+ return lax .dynamic_update_slice (F01 , block , (0 , start ))
879+
880+ F01_pad = lax .fori_loop (0 , n_blocks , body , F01_0 )
881+ return F01_pad [:, :S1 ]
882+
883+ def operated_matrix_slim_from (self , matrix_slim , extent_index_for_masked_pixel ):
884+ """
885+ Apply the interferometer W~ operator to columns defined on the *slim masked* grid.
886+
887+ The input columns are scattered from the slim masked grid onto the unmasked-extent
888+ rectangular grid (on which W~ is defined), operated on with `apply_operator`, and gathered
889+ back onto the slim masked grid.
890+
891+ Parameters
892+ ----------
893+ matrix_slim
894+ Array of shape (M_pix, n_cols) on the slim masked grid (e.g. the real-space
895+ `mapping_matrix` of an `AbstractLinearObjFuncList`).
896+ extent_index_for_masked_pixel
897+ Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
898+
899+ Returns
900+ -------
901+ ndarray
902+ Array of shape (M_pix, n_cols) equal to W~ applied to each column.
903+ """
904+ import jax .numpy as jnp
905+
906+ matrix_slim = jnp .asarray (matrix_slim , dtype = jnp .float64 )
907+ extent_index_for_masked_pixel = jnp .asarray (
908+ extent_index_for_masked_pixel , dtype = jnp .int32
909+ )
910+
911+ grid_flat = jnp .zeros ((self .M , matrix_slim .shape [1 ]), dtype = jnp .float64 )
912+ grid_flat = grid_flat .at [extent_index_for_masked_pixel , :].set (matrix_slim )
913+
914+ return self .apply_operator (grid_flat )[extent_index_for_masked_pixel , :]
915+
916+ def curvature_matrix_off_diag_func_list_from (
917+ self ,
918+ curvature_weights , # (M_pix, n_funcs)
919+ extent_index_for_masked_pixel , # (M_pix,) slim -> extent(flat)
920+ rows ,
921+ cols ,
922+ vals , # triplets where rows are EXTENT indices
923+ * ,
924+ S : int ,
925+ ):
926+ """
927+ Compute the mapper–linear-function off-diagonal block Aᵀ W~ B.
928+
929+ This is the interferometer counterpart of
930+ `ImagingSparseOperator.curvature_matrix_off_diag_func_list_from`, but with one important
931+ difference in what `curvature_weights` must contain.
932+
933+ For imaging the operator is split as W = Hᵀ N⁻¹ H, so the imaging method is passed
934+ `curvature_weights = (H B) / noise²` (the forward blur and the inverse variance are folded
935+ into the input) and only applies Hᵀ internally.
936+
937+ For an interferometer the whole operator W~ = Re(Fᴴ W F) is applied by `apply_operator`,
938+ with the inverse-variance weighting *already inside* W~. Therefore `curvature_weights` is
939+ the plain real-space `mapping_matrix` of the linear function list on the slim masked grid,
940+ with **no** noise weighting and **no** forward operator applied.
941+
942+ The returned matrix is:
943+
944+ off_diag = Aᵀ W~ B
945+
946+ which has shape (S, n_funcs).
947+
948+ Parameters
949+ ----------
950+ curvature_weights
951+ Array of shape (M_pix, n_funcs) on the *slim masked* grid: the un-operated,
952+ un-weighted real-space mapping matrix of the linear function list.
953+ extent_index_for_masked_pixel
954+ Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
955+ Used to scatter values onto the rectangular grid W~ is defined on.
956+ rows, cols, vals
957+ COO triplets for the mapper A, where:
958+ - `rows` are extent-grid indices (flat), shape (nnz,)
959+ - `cols` are source pixel indices, shape (nnz,)
960+ - `vals` are mapping weights, shape (nnz,)
961+ S
962+ Number of source pixels / parameters in the mapper.
963+
964+ Returns
965+ -------
966+ ndarray
967+ Off-diagonal block of shape (S, n_funcs).
968+
969+ Notes
970+ -----
971+ - No `batch_size` sweep is required because the operator is applied to `n_funcs` columns
972+ (typically a handful) rather than to all S source pixels.
973+ """
974+ import jax .numpy as jnp
975+ from jax .ops import segment_sum
976+
977+ curvature_weights = jnp .asarray (curvature_weights , dtype = jnp .float64 )
978+ extent_index_for_masked_pixel = jnp .asarray (
979+ extent_index_for_masked_pixel , dtype = jnp .int32
980+ )
981+
982+ rows = jnp .asarray (rows , dtype = jnp .int32 )
983+ cols = jnp .asarray (cols , dtype = jnp .int32 )
984+ vals = jnp .asarray (vals , dtype = jnp .float64 )
985+
986+ n_funcs = curvature_weights .shape [1 ]
987+
988+ # 1) scatter slim -> extent(flat)
989+ grid_flat = jnp .zeros ((self .M , n_funcs ), dtype = jnp .float64 )
990+ grid_flat = grid_flat .at [extent_index_for_masked_pixel , :].set (
991+ curvature_weights
992+ )
993+
994+ # 2) apply W~ on the extent grid
995+ operated = self .apply_operator (grid_flat ) # (M, n_funcs)
996+
997+ # 3) gather at the mapper's rows (extent coords) and accumulate to source pixels
998+ contrib = vals [:, None ] * operated [rows , :]
999+ return segment_sum (contrib , cols , num_segments = S ) # (S, n_funcs)
1000+
1001+ def curvature_matrix_func_list_from (
1002+ self ,
1003+ curvature_weights_0 , # (M_pix, n_funcs_0)
1004+ curvature_weights_1 , # (M_pix, n_funcs_1)
1005+ extent_index_for_masked_pixel , # (M_pix,) slim -> extent(flat)
1006+ ):
1007+ """
1008+ Compute a linear-function–linear-function curvature block B0ᵀ W~ B1.
1009+
1010+ The imaging sparse inversion forms this block as a plain dot product of noise-weighted,
1011+ PSF-convolved mapping matrices, because for imaging those matrices are already in the
1012+ data frame. For an interferometer the equivalent dense construction would require the
1013+ (expensive) visibility-space transformed mapping matrix, which the sparse formalism exists
1014+ to avoid. Because W~ = Re(Fᴴ W F) is exact and translationally invariant on the extent
1015+ grid, the block is instead formed directly through the same operator used by every other
1016+ block, which is both cheaper and keeps every block of `F` self-consistent.
1017+
1018+ Parameters
1019+ ----------
1020+ curvature_weights_0, curvature_weights_1
1021+ The un-operated, un-weighted real-space `mapping_matrix` of each linear function list,
1022+ on the slim masked grid, of shape (M_pix, n_funcs).
1023+ extent_index_for_masked_pixel
1024+ Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
1025+
1026+ Returns
1027+ -------
1028+ ndarray
1029+ Curvature block of shape (n_funcs_0, n_funcs_1).
1030+ """
1031+ import jax .numpy as jnp
1032+
1033+ curvature_weights_0 = jnp .asarray (curvature_weights_0 , dtype = jnp .float64 )
1034+
1035+ operated = self .operated_matrix_slim_from (
1036+ matrix_slim = curvature_weights_1 ,
1037+ extent_index_for_masked_pixel = extent_index_for_masked_pixel ,
1038+ )
1039+
1040+ return curvature_weights_0 .T @ operated
0 commit comments