@@ -354,7 +354,33 @@ def _init_joint_optimization(self):
354354 # The Levenberg-Marquardt loop
355355 # -----------------------------
356356
357- def solve_joint_optimization (self , xp = np , x0 = None ):
357+ def _gauge_project_dpsi (self , dpsi_vec : np .ndarray ) -> np .ndarray :
358+ """
359+ Project a dpsi vector onto the gauge-constrained subspace
360+ <dpsi, 1> = <dpsi, x> = <dpsi, y> = 0 by removing its constant and
361+ linear (deflection-drift) modes.
362+
363+ These modes are the null space of the Hamiltonian dpsi -> dkappa
364+ map, so the projection leaves the physical convergence correction
365+ unchanged. It is needed when warm-starting a ``gauge_constraints``
366+ solve from an un-gauged ``x0`` (e.g. the one-shot
367+ ``FitDpsiSrcImaging`` solution, which is not gauge-fixed). Each
368+ accepted LM step re-imposes the gauge on the updated state
369+ (C (x + dx) = 0), but from a near-optimal un-gauged start the
370+ gauge-fixing move slides along the model-degenerate constant /
371+ deflection-drift modes and so is not cost-decreasing: it is rejected
372+ and the solve stalls off the constraint surface. Projecting ``x0``
373+ up front places the warm start on the surface, where a cold start
374+ from zero already begins.
375+ """
376+ dpsi_vec = np .asarray (dpsi_vec , dtype = float )
377+ x = self .dpsi_points [:, 1 ]
378+ y = self .dpsi_points [:, 0 ]
379+ basis = np .column_stack ([np .ones_like (x ), x , y ])
380+ coeffs , * _ = np .linalg .lstsq (basis , dpsi_vec , rcond = None )
381+ return dpsi_vec - basis @ coeffs
382+
383+ def solve_joint_optimization (self , xp = np , x0 = None , gauge_project_x0 = False ):
358384 """
359385 Runs the Levenberg-Marquardt loop on the combined state
360386 x = [s | dpsi], accepting steps that decrease the penalized cost and
@@ -373,17 +399,32 @@ def solve_joint_optimization(self, xp=np, x0=None):
373399 the one-shot ``FitDpsiSrcImaging``): the LM then refines inside
374400 that basin rather than searching from zero — the recipe
375401 certified by the interferometer uv campaign (PyAutoLens#627).
402+ gauge_project_x0
403+ When warm-starting a ``gauge_constraints`` solve, project the
404+ dpsi component of ``x0`` onto the gauge subspace
405+ <dpsi, 1> = <dpsi, x> = <dpsi, y> = 0 before iterating (see
406+ :meth:`_gauge_project_dpsi`). Defaults to ``False`` (``x0`` used
407+ verbatim); set ``True`` when ``x0`` is not itself gauge-fixed —
408+ a near-optimal un-gauged start cannot reach the constraint
409+ surface without a non-cost-decreasing move, which the LM
410+ rejects, so it would otherwise stall with the gauge violated.
411+ The dkappa recovery is unaffected by the projection.
376412 """
377413 n_s , n_dpsi = self ._init_joint_optimization ()
378414
379415 if x0 is None :
380416 x = xp .zeros (n_s + n_dpsi )
381417 else :
382- x = xp . asarray ( np .asarray (x0 , dtype = float ) )
383- if x .shape != (n_s + n_dpsi ,):
418+ x0 = np .asarray (x0 , dtype = float )
419+ if x0 .shape != (n_s + n_dpsi ,):
384420 raise ValueError (
385- f"x0 has shape { x .shape } ; expected ({ n_s + n_dpsi } ,)"
421+ f"x0 has shape { x0 .shape } ; expected ({ n_s + n_dpsi } ,)"
422+ )
423+ if gauge_project_x0 :
424+ x0 = np .concatenate (
425+ [x0 [:n_s ], self ._gauge_project_dpsi (x0 [n_s :])]
386426 )
427+ x = xp .asarray (x0 )
387428
388429 data_slim = xp .asarray (np .asarray (self .masked_imaging .data .slim ))
389430 inv_var = xp .asarray (self .inverse_noise_variance )
0 commit comments