You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to the euclid-dr1-prep phase 8 audit (#522, PR #523). MeshGeometryDelaunay.areas_for_magnification returns scipy Voronoi cell areas (unbounded cells zeroed), but the Delaunay mapper is a barycentric-linear interpolant whose exact quadrature weights are the barycentric dual areas (Σ triangle_area/3 per vertex). The audit proved the Voronoi denominator biases magnification by −13 % to −53 % on adaptive-style meshes and −95 % to −99 % when the source fills the hull, while the dual areas recover the identity-lens μ = 1.0 to ≤ 2e-5 for every source shape tested. The dual areas are already computed by both interpolator paths (numpy scipy_delaunay, in-graph JAX jax_delaunay) and then discarded after the split-point offsets. This task makes areas_for_magnification return them, keeps the Voronoi areas under their own name, flips the two phase-8 tests that pin the old semantics, adds the identity-lens regression test, and fixes two incidental _plot_delaunay docstring/triangles= items.
Scope stays as filed: no rectangular-mesh fix (cluster epic lead), no DelaunayNN/KNN area definitions, no library-level magnification API.
Plan
Thread the barycentric dual areas that scipy_delaunay / jax_delaunay already compute through DelaunayInterface into MeshGeometryDelaunay, so areas_for_magnification returns the exact quadrature weight of the piecewise-linear reconstruction and stays trace-safe under JAX.
Keep voronoi_areas / voronoi_areas_numpy untouched and available under their own name; give standalone MeshGeometryDelaunay construction (tests) a numpy fallback that computes the dual areas from a scipy triangulation.
Rewrite the areas_for_magnification docstring to say which quadrature it is and why.
Correct _plot_delaunay: drop the Gouraud claim (matplotlib tripcolor defaults to flat shading) and pass the mapper's own simplices via triangles= so the plot cannot diverge from the mapper's triangulation.
Flip the two phase-8 tests that deliberately pinned the Voronoi semantics, and add a mapper-level identity-lens regression test (Σ (mapping_matrix @ s) × pixel_area == Σ s × areas_for_magnification) plus a JAX-parity test.
Run the full autoarray suite, ship the library PR with pending-release, and post the identity-test numbers here.
Detailed implementation plan
Affected Repositories
PyAutoArray (primary, only repo edited)
autolens_workspace (read-only: the four source_science.py scripts call the same attribute name, so no workspace code change — option (iii))
~/Code/PyAutoLabs-wt/delaunay-dual-area-magnification/ (cloud session: working-directory clone, no worktree)
Facts established by the audit (do not re-derive)
Dual areas are already computed on both interpolator paths: numpy scipy_delaunay (autoarray/inversion/mesh/interpolator/delaunay.py:39, via barycentric_dual_area_from:342-398) and in-graph JAX jax_delaunay (:309-319, masked scatter-add over simplices_padded), then used only for areas_factor * sqrt(areas) split-point offsets and discarded.
DelaunayInterface (:589-608) carries the results; InterpolatorDelaunay.mesh_geometry (:657-668) builds MeshGeometryDelaunay(mesh, mesh_grid, data_grid, xp) with no area information.
The matern variants scipy_delaunay_matern / jax_delaunay_matern (:440-495, used when mesh.skip_areas) return no areas.
PyAutoLens latents run inside a per-sample JAX jit, so areas_for_magnification must be trace-safe under xp=jnp: expose the interpolator's in-graph areas, never call scipy at latent time.
Padded -1 simplex rows are harmless to barycentric_dual_area_from (a -1 index wraps to the last vertex → degenerate zero-area triangle), verified identical to the unpadded result.
Implementation Steps
autoarray/inversion/mesh/interpolator/delaunay.py
scipy_delaunay and jax_delaunay append the dual areas to their return tuple.
scipy_delaunay_matern / jax_delaunay_matern compute them too, via one definition: barycentric_dual_area_from(points, simplices[simplices[:, 0] >= 0], xp=…) (numpy) and the in-graph scatter-add (JAX), so the matern path is trace-safe as well.
DelaunayInterface.__init__(..., dual_areas=None) gains a dual_areas attribute (keyword with default, so DelaunayNNInterface and the KNN builders that construct it positionally keep working).
InterpolatorDelaunay.delaunay unpacks the extra element and passes dual_areas= to the interface; InterpolatorDelaunay.mesh_geometry passes dual_areas=self.delaunay.dual_areas.
MeshGeometryDelaunay.__init__(..., dual_areas=None, xp=np) stores it.
areas_for_magnification returns the supplied dual_areas; when None (standalone construction in tests), fall back to numpy barycentric_dual_area_from(mesh_grid_xy, scipy.spatial.Delaunay(mesh_grid_xy).simplices).
Docstring: tripcolor default is flat shading (per-triangle mean of the three vertices), whose integral equals the Gouraud/dual-area integral by linearity — not Gouraud.
Pass triangles= from mapper.interpolator.delaunay.simplices with the -1 padded rows dropped, guarded so a mapper without a delaunay attribute (KNN, mock) still falls back to matplotlib's own triangulation.
Tests — flip the phase-8 pins deliberately:
test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py: bounded_boundary_cells_are_kept → areas_for_magnification__equals_barycentric_dual_area (indices 3 and 4 equal their dual areas; nothing zeroed); uniform_lattice → interior cells 1.0, sum == (n-1)**2 (the hull area); repeat_calls_agree stays (adapt the sentinel assertion to voronoi_areas, which still carries -1).
test_autoarray/inversion/pixelization/interpolator/test_delaunay.py: the sums_to_convex_hull_area divergence assertion becomes an equality for areas_for_magnification on a MeshGeometryDelaunay built from the same points; keep the Voronoi-ratio pin only as a statement about voronoi_areas_numpy if retained; update the block comment.
New mapper-level identity test in test_autoarray/inversion/pixelization/mappers/test_delaunay.py: build a MapperDelaunay whose mesh outer ring is pinned to the data footprint (no data pixel takes the out-of-hull nearest-vertex fallback), random positive s; assert Σ (mapping_matrix @ s) × pixel_area == Σ s × mapper.mesh_geometry.areas_for_magnification to rel 1e-4.
JAX parity of areas_for_magnification under the directory's requires_jax skipif pattern (test_knn_barycentric.py): InterpolatorDelaunay(..., xp=jnp).mesh_geometry.areas_for_magnification equals the numpy path.
pytest test_autoarray -x; ship_library → PR-open with pending-release. ## API Changes: Changed behaviour — MeshGeometryDelaunay.areas_for_magnification returns barycentric dual areas (was Voronoi cell areas with unbounded cells zeroed); Added — MeshGeometryDelaunay(dual_areas=), DelaunayInterface.dual_areas. Workspace impact: option (iii), no code change. Post the identity-test numbers on this issue.
MeshGeometryDelaunay.areas_for_magnification
(PyAutoArray/autoarray/inversion/mesh/mesh_geometry/delaunay.py:195-207) returns Voronoi cell areas, with only the strictly unbounded cells zeroed via the -1
sentinel at :205. But the Delaunay mapper is a barycentric-linear interpolant: pixel_weights_delaunay_from
(PyAutoArray/autoarray/inversion/mesh/interpolator/delaunay.py:509-586) forms the three
sub-triangle areas at :546-552 and normalises by their sum at :556-560 — textbook
barycentric coordinates. For a piecewise-linear function on a triangulation, ∫_hull f = Σ_tri area_tri · mean(3 vertices) = Σ_i s_i · dual_i, so Σ sᵢ·dual_areaᵢ
with barycentric_dual_area_from (.../interpolator/delaunay.py:342-398) is the exact integral of the reconstruction, not an approximation. That dual area is
already computed one module away and is currently used only to position regularisation
split points.
The identity-lens test settles it numerically. With the source-plane data grid equal to
the image-plane data grid the true magnification is exactly 1.0. The dual areas recover
μ = 1.0 to ≤ 2e-5 in every configuration tested (five source shapes including a
random positive reconstruction, three mesh resolutions) — the identity is geometric, not
a property of the source. The Voronoi areas never do: +0.03 %…+9.3 % on a hull pinned to
the data square (error growing monotonically with reconstruction weight near the
boundary), −13 %…−53 % on an adaptive-style mesh drawn from the source's own
brightness, and −95 %…−99 % when the source fills the hull. The mechanism is that
scipy hands some bounded boundary sites enormous Voronoi cells (circumcentres of very
flat boundary triangles): bounded cells one ring inside the convex hull reach 1e5×
their dual area, with one cell measured at 973 arcsec² on a hull of area 4.15 arcsec²
(234× the entire mesh). The apparent ±0.4 % accuracy for a perfectly compact source is a
knife edge — those pathological cells simply multiply a reconstruction value of ≈ 0, and
a pedestal of one part in ten thousand of the peak already costs −3 %…−5 %. A real
non-negative solver will not sit on that knife edge. Direction of the bias on realistic
meshes is negative: magnification systematically under-estimated. zeroed_pixels
cannot rescue it — hull-ring peeling shows ring 0 (the convex hull itself) has zero
pathological cells because they are already unbounded and zeroed, while ring 1 carries
14 of them (max area/dual 17374.9); zeroing one hull ring moves the flat-source bias from
−97.63 % to −97.69 %, i.e. nothing.
Consumers
areas_for_magnification has no library caller. Its only consumers are the four source_science.py pixelized scripts in autolens_workspace — scripts/imaging/features/pixelization/source_science.py:399, scripts/group/features/pixelization/source_science.py:404, scripts/multi_galaxy/features/pixelization/source_science.py:411, scripts/interferometer/features/pixelization/source_science.py:387 — and only when the
user swaps the mesh to a Delaunay one (all four ship with a rectangular mesh; see the
lead recorded in draft/test/workspaces/mesh_magnification_correctness.md). The Euclid
pipeline's latent.magnification never touches this code at all — it is broken for a
different reason, filed separately as draft/bug/autolens/magnification_latent_zero_for_pixelized_source.md.
Proposed fix (for the implementer to verify, not to take on trust)
Make the Delaunay areas_for_magnification return the barycentric dual areas,
reusing barycentric_dual_area_from on mesh_grid_xy plus the interpolator's
simplices. (Padded -1 simplex rows are harmless: -1 indexing wraps to the last
vertex and gives a degenerate zero-area triangle — verified identical to the unpadded
result.)
Keep the Voronoi areas available under their own name; they are correct arithmetic,
just the wrong quantity for this use.
Update the docstring to say which quadrature it is and why.
Add the identity-lens regression test from the audit's part1_flux_integral.py
construction — F_dual == Σ (mapping_matrix @ s) × pixel_area on a hull pinned to the
data footprint, with the outer mesh ring pinned un-jittered so no data pixel takes the
out-of-hull nearest-vertex fallback (interpolator/delaunay.py:568-580) — rather than
re-pinning the existing snapshot. areas_for_magnification has no direct test in any
repository today, and the one test that touches voronoi_areas
(PyAutoArray/test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py:41-57)
pins the pathology: it asserts a 29.8 arcsec² boundary cell on a mesh whose real cells
are O(1).
The two phase-8 tests that pin the current semantics — test__areas_for_magnification__bounded_boundary_cells_are_kept and the sums_to_convex_hull_area divergence assertion — must be flipped deliberately in
the same PR, not left to fail.
Two incidental docstring items to fold in
_plot_delaunay (PyAutoArray/autoarray/plot/inversion.py:269-322) calls ax.tripcolor(...) at :319 with neither shading= nor triangles=. Its docstring at :272 claims Gouraud shading, but matplotlib defaults to shading='flat'; with
point-valued C flat shading paints each triangle with the mean of its three vertices,
whose area-weighted sum equals the Gouraud integral by linearity, so the inaccuracy is
integral-neutral — but the docstring should not be relied on and should be corrected.
Second, without triangles= matplotlib rebuilds its own Qhull triangulation instead of
reusing mapper.interpolator.delaunay.simplices; on the audit's test mesh the two
simplex sets were identical (780 vs 780 triangles, 0 differences), so this is latent
fragility (co-circular points could diverge), not an active defect — pass triangles=mapper.interpolator.delaunay.simplices. Note that the plotted surface already
integrates to F_dual exactly (1.00000000) and exceeds F_vor by 18.47×: the picture
and the mapper agree with each other, and areas_for_magnification is the odd one out.
Provenance
Proven by the euclid-dr1-prep phase 8 audit, PyAutoArray#522 (audit posted on the issue);
reproduction scripts part1_flux_integral.py, part1b_irregular_hull.py, part1c_pedestal.py, part1d_zeroed_ring.py were in the session scratchpad — the
implementer re-derives from the construction described here.
Gate note
Cortex phase 7 (PyAutoCortex/phases/euclid/magnification_robustness.md) must not score
its Delaunay rung with the current denominator until this ships.
Implementation design (architect, 2026-09-04 — approved plan; execute from this)
Sequence:/prm PyAutoArray#523 first (phase 8 audit PR, CI green) — it frees the PyAutoArray
claim and this fix edits the same files, so branch from main after the merge, no parallel worktree.
Then /start_dev this prompt → task delaunay-dual-area-magnification, worktree PyAutoArray only.
Facts (do not re-derive): the dual areas are already computed by both interpolator paths —
numpy scipy_delaunay (autoarray/inversion/mesh/interpolator/delaunay.py:39, via barycentric_dual_area_from:342-398) and in-graph JAX jax_delaunay (:309-318, scatter-add on simplices_padded) — then discarded after the areas_factor * sqrt(areas) split-point offsets. DelaunayInterface (:589-608) carries the results; InterpolatorDelaunay.mesh_geometry
(:657-668) builds MeshGeometryDelaunay(mesh, mesh_grid, data_grid, xp) with no area information.
The matern variants (:440-495) return no areas. PyAutoLens latents run inside a per-sample JAX jit
(LatentLens.BATCH_MODE = "jit"), so areas_for_magnification must be trace-safe under xp=jnp:
expose the interpolator's in-graph areas, never call scipy at latent time.
Changes:
interpolator/delaunay.py — scipy_delaunay and jax_delaunay add the dual areas to their
return tuple; the matern variants compute them too via barycentric_dual_area_from(points, simplices[simplices[:, 0] >= 0]) (one definition); DelaunayInterface gains dual_areas; InterpolatorDelaunay.mesh_geometry passes dual_areas=self.delaunay.dual_areas.
mesh_geometry/delaunay.py — MeshGeometryDelaunay.__init__(..., dual_areas=None); areas_for_magnification returns the supplied dual_areas, else a numpy fallback barycentric_dual_area_from(mesh_grid_xy, scipy.spatial.Delaunay(mesh_grid_xy).simplices) for
standalone construction (tests). voronoi_areas / voronoi_areas_numpy untouched. Docstring:
dual areas = exact integral of the piecewise-linear reconstruction.
plot/inversion.py::_plot_delaunay — fix the Gouraud claim (tripcolor default is flat) and pass triangles= from the mapper's simplices with the -1 padded rows dropped.
Tests — flip the phase-8 pins deliberately: in test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py, bounded_boundary_cells_are_kept → areas_for_magnification__equals_barycentric_dual_area
(indices 3 and 4 equal their dual areas, none zeroed); uniform_lattice → interior 1.0, sum == (n-1)**2 (hull area); repeat_calls_agree stays. In test_autoarray/inversion/pixelization/interpolator/test_delaunay.py the sums_to_convex_hull_area divergence assertion becomes equality for areas_for_magnification. New mapper-level identity test (mapper test file for Delaunay): a MapperDelaunay whose mesh
outer ring is pinned to the data footprint, random positive s: Σ (mapping_matrix @ s) × pixel_area == Σ s × mesh_geometry.areas_for_magnification to rel 1e-4;
JAX parity of areas_for_magnification under the directory's requires_jax pattern.
pytest test_autoarray -x; ship_library → PR-open, pending-release. ## API Changes:
Changed behaviour — MeshGeometryDelaunay.areas_for_magnification returns barycentric dual areas
(was Voronoi cell areas with unbounded cells zeroed); Added MeshGeometryDelaunay(dual_areas=), DelaunayInterface.dual_areas. Workspace impact: the four source_science.py scripts call the same
attribute — no code change, option (iii). Post the identity-test numbers on the issue.
Out of scope: rectangular areas (cluster epic lead), DelaunayNN/KNN area definitions, a library-level
magnification API.
Overview
Follow-up to the euclid-dr1-prep phase 8 audit (#522, PR #523).
MeshGeometryDelaunay.areas_for_magnificationreturns scipy Voronoi cell areas (unbounded cells zeroed), but the Delaunay mapper is a barycentric-linear interpolant whose exact quadrature weights are the barycentric dual areas (Σ triangle_area/3per vertex). The audit proved the Voronoi denominator biases magnification by −13 % to −53 % on adaptive-style meshes and −95 % to −99 % when the source fills the hull, while the dual areas recover the identity-lens μ = 1.0 to ≤ 2e-5 for every source shape tested. The dual areas are already computed by both interpolator paths (numpyscipy_delaunay, in-graph JAXjax_delaunay) and then discarded after the split-point offsets. This task makesareas_for_magnificationreturn them, keeps the Voronoi areas under their own name, flips the two phase-8 tests that pin the old semantics, adds the identity-lens regression test, and fixes two incidental_plot_delaunaydocstring/triangles=items.Scope stays as filed: no rectangular-mesh fix (cluster epic lead), no DelaunayNN/KNN area definitions, no library-level magnification API.
Plan
scipy_delaunay/jax_delaunayalready compute throughDelaunayInterfaceintoMeshGeometryDelaunay, soareas_for_magnificationreturns the exact quadrature weight of the piecewise-linear reconstruction and stays trace-safe under JAX.voronoi_areas/voronoi_areas_numpyuntouched and available under their own name; give standaloneMeshGeometryDelaunayconstruction (tests) a numpy fallback that computes the dual areas from a scipy triangulation.areas_for_magnificationdocstring to say which quadrature it is and why._plot_delaunay: drop the Gouraud claim (matplotlibtripcolordefaults to flat shading) and pass the mapper's own simplices viatriangles=so the plot cannot diverge from the mapper's triangulation.Σ (mapping_matrix @ s) × pixel_area == Σ s × areas_for_magnification) plus a JAX-parity test.pending-release, and post the identity-test numbers here.Detailed implementation plan
Affected Repositories
source_science.pyscripts call the same attribute name, so no workspace code change — option (iii))Branch Survey
Suggested branch:
feature/delaunay-dual-area-magnificationWork Classification
Library
Worktree root
~/Code/PyAutoLabs-wt/delaunay-dual-area-magnification/(cloud session: working-directory clone, no worktree)Facts established by the audit (do not re-derive)
scipy_delaunay(autoarray/inversion/mesh/interpolator/delaunay.py:39, viabarycentric_dual_area_from:342-398) and in-graph JAXjax_delaunay(:309-319, masked scatter-add oversimplices_padded), then used only forareas_factor * sqrt(areas)split-point offsets and discarded.DelaunayInterface(:589-608) carries the results;InterpolatorDelaunay.mesh_geometry(:657-668) buildsMeshGeometryDelaunay(mesh, mesh_grid, data_grid, xp)with no area information.scipy_delaunay_matern/jax_delaunay_matern(:440-495, used whenmesh.skip_areas) return no areas.areas_for_magnificationmust be trace-safe underxp=jnp: expose the interpolator's in-graph areas, never call scipy at latent time.-1simplex rows are harmless tobarycentric_dual_area_from(a-1index wraps to the last vertex → degenerate zero-area triangle), verified identical to the unpadded result.Implementation Steps
autoarray/inversion/mesh/interpolator/delaunay.pyscipy_delaunayandjax_delaunayappend the dualareasto their return tuple.scipy_delaunay_matern/jax_delaunay_materncompute them too, via one definition:barycentric_dual_area_from(points, simplices[simplices[:, 0] >= 0], xp=…)(numpy) and the in-graph scatter-add (JAX), so the matern path is trace-safe as well.DelaunayInterface.__init__(..., dual_areas=None)gains adual_areasattribute (keyword with default, soDelaunayNNInterfaceand the KNN builders that construct it positionally keep working).InterpolatorDelaunay.delaunayunpacks the extra element and passesdual_areas=to the interface;InterpolatorDelaunay.mesh_geometrypassesdual_areas=self.delaunay.dual_areas.autoarray/inversion/mesh/mesh_geometry/delaunay.pyMeshGeometryDelaunay.__init__(..., dual_areas=None, xp=np)stores it.areas_for_magnificationreturns the supplieddual_areas; whenNone(standalone construction in tests), fall back to numpybarycentric_dual_area_from(mesh_grid_xy, scipy.spatial.Delaunay(mesh_grid_xy).simplices).voronoi_areas/voronoi_areas_numpyuntouched.∫ f = Σ s_i · dual_i); they tile the convex hull; why the Voronoi cells are the wrong quantity for this use (cite audit: Delaunay pixel areas and magnification paths (euclid-dr1-prep phase 8) #522).autoarray/plot/inversion.py::_plot_delaunaytripcolordefault is flat shading (per-triangle mean of the three vertices), whose integral equals the Gouraud/dual-area integral by linearity — not Gouraud.triangles=frommapper.interpolator.delaunay.simpliceswith the-1padded rows dropped, guarded so a mapper without adelaunayattribute (KNN, mock) still falls back to matplotlib's own triangulation.test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py:bounded_boundary_cells_are_kept→areas_for_magnification__equals_barycentric_dual_area(indices 3 and 4 equal their dual areas; nothing zeroed);uniform_lattice→ interior cells 1.0, sum ==(n-1)**2(the hull area);repeat_calls_agreestays (adapt the sentinel assertion tovoronoi_areas, which still carries-1).test_autoarray/inversion/pixelization/interpolator/test_delaunay.py: thesums_to_convex_hull_areadivergence assertion becomes an equality forareas_for_magnificationon aMeshGeometryDelaunaybuilt from the same points; keep the Voronoi-ratio pin only as a statement aboutvoronoi_areas_numpyif retained; update the block comment.test_autoarray/inversion/pixelization/mappers/test_delaunay.py: build aMapperDelaunaywhose mesh outer ring is pinned to the data footprint (no data pixel takes the out-of-hull nearest-vertex fallback), random positives; assertΣ (mapping_matrix @ s) × pixel_area == Σ s × mapper.mesh_geometry.areas_for_magnificationto rel 1e-4.areas_for_magnificationunder the directory'srequires_jaxskipif pattern (test_knn_barycentric.py):InterpolatorDelaunay(..., xp=jnp).mesh_geometry.areas_for_magnificationequals the numpy path.pytest test_autoarray -x;ship_library→ PR-open withpending-release.## API Changes: Changed behaviour —MeshGeometryDelaunay.areas_for_magnificationreturns barycentric dual areas (was Voronoi cell areas with unbounded cells zeroed); Added —MeshGeometryDelaunay(dual_areas=),DelaunayInterface.dual_areas. Workspace impact: option (iii), no code change. Post the identity-test numbers on this issue.Key Files
autoarray/inversion/mesh/interpolator/delaunay.py—scipy_delaunay,jax_delaunay, matern variants,barycentric_dual_area_from,DelaunayInterface,InterpolatorDelaunay.delaunay/.mesh_geometryautoarray/inversion/mesh/mesh_geometry/delaunay.py—MeshGeometryDelaunay.areas_for_magnification,voronoi_areasautoarray/inversion/mesh/interpolator/sibson.py,knn.py— subclasses constructingDelaunayInterface(must keep working unchanged)autoarray/plot/inversion.py—_plot_delaunaytest_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py,.../interpolator/test_delaunay.py,.../mappers/test_delaunay.pyVerification
pytest test_autoarray/inversion -qthenpytest test_autoarray -q -n autogreen.areas_for_magnification; the numbers are posted here.Original Prompt
Click to expand starting prompt
Delaunay magnification denominator uses Voronoi cell areas; the barycentric-linear mapper's exact quadrature weight is the dual area
Type: bug
Target: autoarray
Repos:
Themes:
Difficulty: small-medium
Autonomy: supervised
Priority: high
Status: formalised
Consequence: judge
Review-minutes: 20
Unattended: ready
Epic: euclid-dr1-prep
Parent: draft/feature/euclid/euclid_dr1_prep_epic.md
Filed: 2026-09-04
The finding
MeshGeometryDelaunay.areas_for_magnification(
PyAutoArray/autoarray/inversion/mesh/mesh_geometry/delaunay.py:195-207) returnsVoronoi cell areas, with only the strictly unbounded cells zeroed via the
-1sentinel at
:205. But the Delaunay mapper is a barycentric-linear interpolant:pixel_weights_delaunay_from(
PyAutoArray/autoarray/inversion/mesh/interpolator/delaunay.py:509-586) forms the threesub-triangle areas at
:546-552and normalises by their sum at:556-560— textbookbarycentric coordinates. For a piecewise-linear function on a triangulation,
∫_hull f = Σ_tri area_tri · mean(3 vertices) = Σ_i s_i · dual_i, soΣ sᵢ·dual_areaᵢwith
barycentric_dual_area_from(.../interpolator/delaunay.py:342-398) is theexact integral of the reconstruction, not an approximation. That dual area is
already computed one module away and is currently used only to position regularisation
split points.
The identity-lens test settles it numerically. With the source-plane data grid equal to
the image-plane data grid the true magnification is exactly 1.0. The dual areas recover
μ = 1.0 to ≤ 2e-5 in every configuration tested (five source shapes including a
random positive reconstruction, three mesh resolutions) — the identity is geometric, not
a property of the source. The Voronoi areas never do: +0.03 %…+9.3 % on a hull pinned to
the data square (error growing monotonically with reconstruction weight near the
boundary), −13 %…−53 % on an adaptive-style mesh drawn from the source's own
brightness, and −95 %…−99 % when the source fills the hull. The mechanism is that
scipy hands some bounded boundary sites enormous Voronoi cells (circumcentres of very
flat boundary triangles): bounded cells one ring inside the convex hull reach 1e5×
their dual area, with one cell measured at 973 arcsec² on a hull of area 4.15 arcsec²
(234× the entire mesh). The apparent ±0.4 % accuracy for a perfectly compact source is a
knife edge — those pathological cells simply multiply a reconstruction value of ≈ 0, and
a pedestal of one part in ten thousand of the peak already costs −3 %…−5 %. A real
non-negative solver will not sit on that knife edge. Direction of the bias on realistic
meshes is negative: magnification systematically under-estimated.
zeroed_pixelscannot rescue it — hull-ring peeling shows ring 0 (the convex hull itself) has zero
pathological cells because they are already unbounded and zeroed, while ring 1 carries
14 of them (max area/dual 17374.9); zeroing one hull ring moves the flat-source bias from
−97.63 % to −97.69 %, i.e. nothing.
Consumers
areas_for_magnificationhas no library caller. Its only consumers are the foursource_science.pypixelized scripts in autolens_workspace —scripts/imaging/features/pixelization/source_science.py:399,scripts/group/features/pixelization/source_science.py:404,scripts/multi_galaxy/features/pixelization/source_science.py:411,scripts/interferometer/features/pixelization/source_science.py:387— and only when theuser swaps the mesh to a Delaunay one (all four ship with a rectangular mesh; see the
lead recorded in
draft/test/workspaces/mesh_magnification_correctness.md). The Euclidpipeline's
latent.magnificationnever touches this code at all — it is broken for adifferent reason, filed separately as
draft/bug/autolens/magnification_latent_zero_for_pixelized_source.md.Proposed fix (for the implementer to verify, not to take on trust)
areas_for_magnificationreturn the barycentric dual areas,reusing
barycentric_dual_area_fromonmesh_grid_xyplus the interpolator'ssimplices. (Padded
-1simplex rows are harmless:-1indexing wraps to the lastvertex and gives a degenerate zero-area triangle — verified identical to the unpadded
result.)
just the wrong quantity for this use.
part1_flux_integral.pyconstruction —
F_dual == Σ (mapping_matrix @ s) × pixel_areaon a hull pinned to thedata footprint, with the outer mesh ring pinned un-jittered so no data pixel takes the
out-of-hull nearest-vertex fallback (
interpolator/delaunay.py:568-580) — rather thanre-pinning the existing snapshot.
areas_for_magnificationhas no direct test in anyrepository today, and the one test that touches
voronoi_areas(
PyAutoArray/test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py:41-57)pins the pathology: it asserts a 29.8 arcsec² boundary cell on a mesh whose real cells
are O(1).
test__areas_for_magnification__bounded_boundary_cells_are_keptand thesums_to_convex_hull_areadivergence assertion — must be flipped deliberately inthe same PR, not left to fail.
Two incidental docstring items to fold in
_plot_delaunay(PyAutoArray/autoarray/plot/inversion.py:269-322) callsax.tripcolor(...)at:319with neithershading=nortriangles=. Its docstring at:272claims Gouraud shading, but matplotlib defaults toshading='flat'; withpoint-valued
Cflat shading paints each triangle with the mean of its three vertices,whose area-weighted sum equals the Gouraud integral by linearity, so the inaccuracy is
integral-neutral — but the docstring should not be relied on and should be corrected.
Second, without
triangles=matplotlib rebuilds its own Qhull triangulation instead ofreusing
mapper.interpolator.delaunay.simplices; on the audit's test mesh the twosimplex sets were identical (780 vs 780 triangles, 0 differences), so this is latent
fragility (co-circular points could diverge), not an active defect — pass
triangles=mapper.interpolator.delaunay.simplices. Note that the plotted surface alreadyintegrates to
F_dualexactly (1.00000000) and exceedsF_vorby 18.47×: the pictureand the mapper agree with each other, and
areas_for_magnificationis the odd one out.Provenance
Proven by the euclid-dr1-prep phase 8 audit, PyAutoArray#522 (audit posted on the issue);
reproduction scripts
part1_flux_integral.py,part1b_irregular_hull.py,part1c_pedestal.py,part1d_zeroed_ring.pywere in the session scratchpad — theimplementer re-derives from the construction described here.
Gate note
Cortex phase 7 (
PyAutoCortex/phases/euclid/magnification_robustness.md) must not scoreits Delaunay rung with the current denominator until this ships.
Implementation design (architect, 2026-09-04 — approved plan; execute from this)
Sequence:
/prm PyAutoArray#523first (phase 8 audit PR, CI green) — it frees the PyAutoArrayclaim and this fix edits the same files, so branch from
mainafter the merge, no parallel worktree.Then
/start_devthis prompt → taskdelaunay-dual-area-magnification, worktree PyAutoArray only.Facts (do not re-derive): the dual areas are already computed by both interpolator paths —
numpy
scipy_delaunay(autoarray/inversion/mesh/interpolator/delaunay.py:39, viabarycentric_dual_area_from:342-398) and in-graph JAXjax_delaunay(:309-318, scatter-add onsimplices_padded) — then discarded after theareas_factor * sqrt(areas)split-point offsets.DelaunayInterface(:589-608) carries the results;InterpolatorDelaunay.mesh_geometry(
:657-668) buildsMeshGeometryDelaunay(mesh, mesh_grid, data_grid, xp)with no area information.The matern variants (
:440-495) return no areas. PyAutoLens latents run inside a per-sample JAX jit(
LatentLens.BATCH_MODE = "jit"), soareas_for_magnificationmust be trace-safe underxp=jnp:expose the interpolator's in-graph areas, never call scipy at latent time.
Changes:
interpolator/delaunay.py—scipy_delaunayandjax_delaunayadd the dualareasto theirreturn tuple; the matern variants compute them too via
barycentric_dual_area_from(points, simplices[simplices[:, 0] >= 0])(one definition);DelaunayInterfacegainsdual_areas;InterpolatorDelaunay.mesh_geometrypassesdual_areas=self.delaunay.dual_areas.mesh_geometry/delaunay.py—MeshGeometryDelaunay.__init__(..., dual_areas=None);areas_for_magnificationreturns the supplieddual_areas, else a numpy fallbackbarycentric_dual_area_from(mesh_grid_xy, scipy.spatial.Delaunay(mesh_grid_xy).simplices)forstandalone construction (tests).
voronoi_areas/voronoi_areas_numpyuntouched. Docstring:dual areas = exact integral of the piecewise-linear reconstruction.
plot/inversion.py::_plot_delaunay— fix the Gouraud claim (tripcolor default is flat) and passtriangles=from the mapper's simplices with the-1padded rows dropped.test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py,bounded_boundary_cells_are_kept→areas_for_magnification__equals_barycentric_dual_area(indices 3 and 4 equal their dual areas, none zeroed);
uniform_lattice→ interior 1.0, sum ==(n-1)**2(hull area);repeat_calls_agreestays. Intest_autoarray/inversion/pixelization/interpolator/test_delaunay.pythesums_to_convex_hull_areadivergence assertion becomes equality forareas_for_magnification.New mapper-level identity test (mapper test file for Delaunay): a
MapperDelaunaywhose meshouter ring is pinned to the data footprint, random positive
s:Σ (mapping_matrix @ s) × pixel_area == Σ s × mesh_geometry.areas_for_magnificationto rel 1e-4;JAX parity of
areas_for_magnificationunder the directory'srequires_jaxpattern.pytest test_autoarray -x;ship_library→ PR-open,pending-release.## API Changes:Changed behaviour —
MeshGeometryDelaunay.areas_for_magnificationreturns barycentric dual areas(was Voronoi cell areas with unbounded cells zeroed); Added
MeshGeometryDelaunay(dual_areas=),DelaunayInterface.dual_areas. Workspace impact: the foursource_science.pyscripts call the sameattribute — no code change, option (iii). Post the identity-test numbers on the issue.
Out of scope: rectangular areas (cluster epic lead), DelaunayNN/KNN area definitions, a library-level
magnification API.