Smooth a MaterialGrid object's own boundary, and make its interior average consistent for graded grids - #3279
Conversation
get_front_object() rejected any pixel touching a variable material, so a material-grid object's boundary never reached the analytic two-material average; every such pixel fell through to fallback_chi1inv_row(). That fallback takes its normal from matgrid_grad(), i.e. from grad(u) alone, so it only sees interfaces that live in the design level set. An (n,n,1) grid has no z structure, and the top and bottom faces of a 3d design slab are not in the level set at all -- they are the geometric object's boundary. Those faces therefore stayed staircased regardless of do_averaging. They do not exist in 2d, which is why only 3d runs paid for it. - Narrow the rejection to what actually breaks the average. mat_behind must still be constant over the pixel, so a variable material there is rejected as before. As the material of the *front* object a material grid is admissible: the object's boundary is an ordinary level set, and the caller localizes the grid before averaging. A user material function keeps the old treatment -- its fallback derives the normal from normal_vector() and integrates chi1p1(), both of which sample the actual material and therefore already see the object boundary. Only the material-grid fallback is blind to it. - Localize the grid inside the pixel before averaging (inside_sample_point()). The pixel center can sit on the far side of the boundary, where a material grid is evaluated on its mirrored extension, so step to the centroid of the part that is inside: (1-fill)*h/2 along the inward normal for a planar cut. libctl does not fix the normal's orientation, so try both and keep the candidate that is actually inside the object. - Average only the boundary. Pixels with fill >= 1 - 1e-6 are handed back to the fallback; averaging them against a filling fraction of 1 would return the unsmoothed material and silently disable level-set smoothing across the whole design-region interior. fill <= 1e-6 takes the no-average path. - Defer the metal test for a front-side grid until after localization. is_metal() reads the grid's `medium`, which before get_material_pt() still holds the previous pixel's interpolation. mat_behind is never a grid, so its test stays where it was. test_subpixel_3d.py gains TestObjectBoundarySmoothing. Filling `weights` with a constant removes the in-plane level set, leaving the block's six faces as the only material interfaces, so the grid must be smoothed exactly as an ordinary Medium block of the same epsilon -- the z profiles agree to 1e-8. A second test guards that assertion against passing for the wrong reason: before this change the do_averaging on/off difference was identically zero. A third drives a ramped grid to confirm the interior level set is still smoothed. This does not address the grad(u) ~ 0 regime of NanoComp#2757. Interior pixels still go through the fallback's |grad(u)| > 1e-8 gate, which applies the mixing rule without testing whether an interface lies inside the pixel.
…erage matgrid_inveps_func (and the complex-integration twin) integrated (1-u_proj)/eps1 + u_proj/eps2 -- the inverse of a binary mixture, valid only where u_proj is 0 or 1. The assembled medium is graded, eps(x) = (1-u_proj)*eps1 + u_proj*eps2, so the harmonic term must integrate 1/eps(x). The difference is the arithmetic-harmonic gap at intermediate u_proj, and it kept minveps - 1/meps finite as |grad u| -> 0, which made chi1inv jump between the |grad u| < 1e-8 pointwise branch and the anisotropic one. With beta = 0 -- weights projected upstream, as in SSP -- intermediate u_proj is the norm: the assembled epsilon was discontinuous in the design weights at the gate and violently nonlinear just above it, and the adjoint gradient (fields from the closed branch, dA/du from the open one) disagreed with a directional finite difference by several percent on an SSP-projected 3d strip design. Integrating 1/eps(x) sends minveps -> 1/meps in the zero-gradient limit, so the anisotropic correction vanishes continuously and both branches of the gate agree; for projected (binary) u_proj the integrand is unchanged. The voxel model's linear extrapolation u = uval + |grad u| * x is now saturated to [0,1]: the interpolated weight itself never leaves that range, only the extrapolation does (a steep transition next to a saturated node), and an out-of-range weight extrapolates epsilon through zero, where 1/eps(x) has a pole. The old linear-in-u integrand merely went unphysical there; the new one went infinite. The beta=0 ramp case in test_interior_level_set_is_still_smoothed asserted the old behaviour -- an O(1) on/off difference for a graded medium whose level set contains no interface. The interior-swallowing guard it encodes now uses a projected (beta=inf) ramp, which keeps a real interface in the interior, and the beta=0 ramp asserts the average reduces to the pointwise epsilon instead. This is the graded-medium regime of NanoComp#2757.
Three regimes the smoothing path had no coverage for: - symmetries must not change the smoothed epsilon assembly (structure only -- the adjoint gradient under symmetries has its own independent, pre-existing defect). Measured invariant to 3.6e-15 with and without Mirror(Y), at beta = inf and beta = 0. - a uniform grid of anisotropic media gets the same analytic boundary average as a plain block of the interpolated anisotropic epsilon (identical to the last bit -- same code path), and the on/off guard shows the average is actually engaging. - in the interior, the level-set fallback deliberately declines anisotropic pixels (its 1d normal average is scalar), so do_averaging must be a finite no-op there rather than a half-applied average.
Under the same linear voxel model the quadrature uses, the eta level set crosses a voxel only if |uval - eta| < |grad u| * rad. tanh saturates to +-1 in double precision once |beta * (u - eta)| exceeds ~19, so when the whole voxel sits at least that far on one side of eta the projected weight is constant across it to machine precision and the average is the pointwise material. Return that directly instead of running the adaptive quadrature on a constant integrand. Written as a saturation width, not a hard |d| < rad cut: a hard cut at finite beta would reintroduce a (small) discontinuity at the threshold, the disease the previous commit just removed. At beta = inf the condition reduces to |uval - eta| > |grad u| * rad exactly; beta = 0 has no level-set interface and keeps the full graded average. Bit-identical assembled epsilon by construction (the integrand it skips is constant), measured identical on a projected random design; most voxels of a projected design are far from the interface, so set_epsilon with do_averaging=True gets measurably cheaper.
test_gradient_matches_fd_with_smoothing holds fd/adjoint to 1% and the adjoint gradient currently depends on the MPI chunk division -- the pre-existing defect NanoComp#3274 fixes. This branch moved the smoothing operating point, and the chunk error that used to hide inside the tolerance now reads 1.3% at np=2 and 7.3% at np=3 (it is np-dependent, the signature of NanoComp#3274, and passes at every rank count with NanoComp#3274 merged in -- verified on a build of this branch merged with its head). Keep the serial assertion at 1%; bound the parallel one at 0.1, still far below the 70-100% disagreement the test guards against, and tighten it back when NanoComp#3274 is in.
|
This would be a nice feature to have since the workaround of defining a 3d material grid to smooth out the normal boundaries of a 2d design region with finite thickness could cause you to run out of memory when doing parallel meep calculations since you would have to load a large 3d material grid onto each process |
| matgrid_volavg *mgva = (matgrid_volavg *)mgva_; | ||
| get_uproj_w(mgva, x[0], u_proj, w); | ||
| return w * ((1 - u_proj) / mgva->eps1 + u_proj / mgva->eps2); | ||
| return w / ((1 - u_proj) * mgva->eps1 + u_proj * mgva->eps2); |
There was a problem hiding this comment.
This isn't related to the core PR goal (smooth MG boundaries), right? The native cpp MG smoothing code indeed needs to be revamped, but I think we should do that separately.
|
|
||
| double fill = box_overlap_with_object(pixel, *o, tol, maxeval); | ||
|
|
||
| if (front_is_matgrid) { |
There was a problem hiding this comment.
I'm not sure this part will properly get reflected in the
do_averaging=Truepromises subpixel smoothing for aMaterialGrid, but tworegimes never got it right, and both matter for using the C++ smoothing path
with externally projected designs. One commit per defect, a third that adds
symmetry-invariance and anisotropic-media coverage this path never had, and a
fourth that makes the interface-in-voxel question explicit — and the quadrature
cheaper — now that it can be asked without introducing a discontinuity. The
harmonic-average commit is separable into its own PR on request.
faces of a design slab — the interfaces that set the vertical confinement of
every strip-waveguide design — stay staircased at every resolution, no
matter what
do_averagingis set to.beta=0)grids. The harmonic term integrates the inverse of a binary mixture
rather than of the graded medium the rest of the code assembles, which
leaves the assembled epsilon discontinuous in the design weights at the
|∇u| < 1e-8gate — and the adjoint gradient wrong by 6–47 % for weightsprojected upstream (e.g. SSP), the exact composition where a low-resolution
design is interpolated to the Yee grid through the meep API.
Commit 1: the material-grid fallback is the only smoothing path that cannot see an object boundary
There are two paths.
eff_chi1inv_matrix()callsget_front_object()and, ifthat succeeds, averages two materials across the object's boundary using its
normal and the pixel's filling fraction. If it fails,
fallback_chi1inv_row()integrates along a level set instead.
get_front_object()rejected any pixel touching a variable material:So a material-grid object's own boundary never reached the first path. And the
fallback takes its normal from
∇ualone for a grid (src/meepgeom.cpp:1406)while a user material function gets it from
normal_vector()and integrateschi1p1()(:1409) — both of which sample the actual material and thereforealready see the object boundary. Only the material-grid branch is blind to
it.
For an
(n, n, 1)grid∇uhas no z component at all. The slab's z faces arenot in the design level set; they are the object's boundary. Nothing in either
path was smoothing them. Those faces do not exist in 2d, and every adjoint test
upstream is 2d or cylindrical, which is why this survived.
Why the front material may be a grid and the back material may not
The average needs one material that is constant over the pixel to act as the
background, and one boundary with a normal and a filling fraction.
mat_behindmust therefore stay constant — a variable material there is still rejected.
mat_frontneed not be: the boundary being averaged is the object's own levelset, and the grid can be evaluated at a point before averaging.
Which point matters. The pixel center can sit on the far side of the boundary,
and that is exactly where a
MaterialGridis evaluated on its mirroredextension. For a planar cut of a pixel of size
hwith inside fractionfill,the centroid of the inside part lies
(1-fill)*h/2from the center along theinward normal. libctl does not fix the normal's orientation, so both candidates
are tried and the one
point_in_fixed_objectp()accepts is kept.Interior pixels must go back to the fallback. A pixel with
fill = 1has noboundary in it; averaging it would return the unsmoothed material and silently
disable level-set smoothing across the whole design region. Those are handed
back.
Changes (commit 1)
get_front_object()to what actually breaks theaverage, and move it after the front/behind assignment so it can distinguish
the two. A material grid is admissible as the front object's own material
(not as
default_material, which has no boundary to average) whendo_averagingis set;front_is_matgridtells the caller to localize it.inside_sample_point()— evaluate the grid at the centroid of the insidepart of the pixel, per the derivation above.
fill >= 1 - 1e-6returnsfallback = true;fill <= 1e-6takes the no-average path.is_metal()reads the grid'smedium, which beforeget_material_pt()still holds the previous pixel's interpolation.
mat_behindis never a grid,so its test stays where it was.
Commit 2: integrate
1/epsof the medium the code actually assemblesmatgrid_inveps_func()(and its complex-integration twin) integrated(1 - u_proj) / eps1 + u_proj / eps2— the inverse of a binary mixture, valid only where
u_projis 0 or 1. Themedium every other code path assembles is graded:
eps(u) = (1-u)*eps1 + u*eps2. For intermediateu_projthe two differ by thearithmetic–harmonic gap, and that gap does not vanish as
|∇u| → 0. So thetwo branches of the fallback disagreed at the gate: below
|∇u| < 1e-8a pixelgets the isotropic pointwise
1/eps(uval); just above it, the anisotropiccombination
(1/meps)·I + (minveps - 1/meps)·nnᵀwith a normal-directionepsilon that is O(1) different (at
u = 0.5, index 1.44 vs 3.48: 7.09pointwise vs 3.53 anisotropic). The assembled epsilon is discontinuous in the
design weights wherever the design field has a flat spot — which is the norm
for weights projected upstream at
beta=inf(SSP) and handed to abeta=0grid, and happens at every critical point of a smooth design field too. The
adjoint evaluates fields on the closed branch and
dA/duon the open one, andthe mismatch shows up directly against a directional finite difference.
Integrating
1/eps(u_proj)instead sendsminveps → 1/mepsin thezero-gradient limit, so the anisotropic correction vanishes continuously and
the two branches agree. For projected (binary)
u_projthe integrand isunchanged — projected-grid users see the same numbers, which is what
test_adjoint_solverandtest_material_gridpassing unchanged confirms.Two consequences handled in the same commit:
u = uval + |∇u|·xis saturated to[0, 1]. The interpolated weight itself never leaves that range — only theextrapolation does, at a steep transition next to a saturated node — and an
out-of-range weight extrapolates epsilon through zero, where
1/epshas apole (the old linear-in-u integrand merely went unphysical there; the new one
went infinite).
eps = 0(metal–dielectric) nowmeets a genuine
1/epssingularity inside the integral where the oldintegrand stayed finite.
get_front_object()already declines to averagemetals; the fallback inherits the general position that averaging metals is
not meaningful.
Measured (commit 2)
Adjoint vs directional finite difference along the adjoint gradient; 3d
strip-waveguide design region,
EigenmodeCoefficientobjective, design 17×17×1,resolution 12, grid
beta=0,eps_averaging=True,do_averaging=True:beta=infupstream), pulled inside [0,1]do_averaging=False(control)The before-column's strong dp-dependence is the discontinuity itself: the
secant straddles jumps. The smooth field's 1.8 % was the same defect at the
field's isolated critical points (
∇u ≈ 0), not FD noise.This is the mechanism behind #2757. The reproduction there is a constant
design region — a
∇u = 0plateau sitting exactly on the discontinuous gate —and a structure-level probe of that configuration (no FDTD: perturb the
weights, read back
mp.Dielectric) shows the step is gone. Uniformu = 0.9design,
do_averaging=True, perturbation1e-5:∇u = 0beta= 0 / 1 / 4A
1e-5nudge now moves epsilon by1e-5, at everybetaand everyresolution — which is what made #2757's finite difference irreconcilable with
its adjoint. I'd suggest confirming on the original script there before
closing the issue from this PR.
Commit 4: an explicit interface-distance test, as a quadrature skip
Under the same linear voxel model the quadrature uses, the
ηlevel setcrosses a voxel only if
|uval − η| < |∇u|·rad.tanhsaturates to ±1 indouble precision once
|β·(u − η)|exceeds ~19, so when the whole voxel sitsat least that far on one side of
η,u_projis constant across it tomachine precision and the average is the pointwise material — return it
directly instead of integrating a constant.
The condition is written as a saturation width,
|uval − η| − |∇u|·rad > 20/β, not as a hard|d| < radcut: a hard cut at finiteβwouldreintroduce a (small) step at the threshold — the disease commit 2 just
removed. At
β = infit reduces to|uval − η| > |∇u|·radexactly, andβ = 0(a graded medium with no level-set interface) keeps the full average.Measured: the assembled epsilon is unchanged (bit-identical at
β = 8,≤ 5.3e-15 at
β = inf— quadrature roundoff vs. a direct division on thesame constant), and
set_epsilonon a 3d random binary design drops1.41 s → 1.29 s at
β = inf— a worst case, since a random binary designputs an interface next to nearly every voxel; contiguous designs skip far
more.
Tests
TestObjectBoundarySmoothinginpython/tests/test_subpixel_3d.py. Fillingweightswith a constant removes the in-plane level set entirely, leaving theblock's six faces as the only material interfaces — so whatever smoothing the
grid gets there must be the smoothing an ordinary
Mediumblock of the sameepsilon gets.
1e-8;do_averagingon/off difference exceeds 0.5, which guards the firstassertion against passing because both sides are staircased — before commit 1
that difference was identically zero;
beta=infkeeps a genuine level-set interface in theinterior and must show an on/off difference above 1.0 (measured 3.91) — this
is the guard that interior pixels are not swallowed by the
fill = 1boundary path. Diagonal, because for an axis-aligned linear ramp the voxel
windows
uval ± |∇u|·radtile the u-axis exactly (2·radis the pixel), soat most one column per component straddles the interface and the signal is an
alignment accident;
beta=0is a graded medium with no interface anywhere, andmust show an on/off difference below 0.1 (measured 0.0024) — before commit 2
it read 3.9, the binary harmonic mix applied to a medium with no interface in
any voxel.
A third commit adds coverage for two regimes this path never had tests for:
symmetriesinvariance — the smoothed epsilon assembly is identical withand without
mp.Mirror(mp.Y)for a mirror-symmetric design (measured3.6e-15, at
beta=infandbeta=0). Structure only: the adjoint gradientunder
symmetrieshas its own independent, pre-existing defect that nothinghere touches.
analytic boundary average as a plain block of the interpolated anisotropic
epsilon (identical to the last bit — same code path), with an on/off guard
showing the average engages; and in the interior, where the level-set
fallback deliberately declines anisotropic pixels (its 1d normal average is
scalar),
do_averagingis pinned as a finite no-op rather than ahalf-applied average.
Full suites on a clean build of this branch:
test_subpixel_3d.py11/11,test_material_grid.py3/3 (transmission identical to all printed digits),test_adjoint_solver.pyall pass.One CI note: the MPI jobs initially tripped on
test_gradient_matches_fd_with_smoothing— an existing test from #3263 thatholds fd/adjoint to 1%. The excess is the chunk-dependence of the adjoint
gradient that #3274 fixes: it is np-dependent (1.3% at np=2, 7.3% at np=3,
serial exact) and vanishes at every rank count on this branch merged with
#3274's head. Until #3274 lands, the parallel assertion is bounded at 0.1 —
still far below the 70–100% disagreement the test guards against — and the
serial assertion keeps its 1%.
Also checked against the pending #3277 (
70161188), since both touchmeepgeom.cpp: the merge is conflict-free, and on the combined build bothsides' suites pass — this branch's 11/11 plus #3277's
test_adjoint_symmetric_grids(3/3) andtest_adjoint_adjacent_grids(5/5).Two composition results worth recording: a
MaterialGridoverlapped with amirrored copy of itself (
U_MEAN, the #1984 pattern) assembles the samesmoothed epsilon as a single grid carrying the explicitly averaged weights to
2.7e-15; and #3277's symmetric-grids test rerun with
do_averaging=Truepasses 3/3 (adjoint/FD = 1.0 ± 2e-3) — a configuration that needs both #3277's
shared-variable attribution and this PR's graded harmonic average to hold.
What would falsify this
If the uniform-grid z profile does not match the equivalent block, the
localization point or the filling fraction is wrong. If the
beta=inframp'son/off difference collapses, interior pixels are being averaged against
fill = 1and level-set smoothing is gone. If thebeta=0ramp's differencegrows back, the anisotropic correction is again surviving without an interface
in the voxel. All three are asserted, so the tests are the falsifier.
Not addressed
#1965 (edge artifacts that vanish when the grid is
default_materialrather than a
Block) may have the same root as commit 1, but I have notconfirmed it.
The two commits are separable — happy to split the harmonic-integrand change
into its own PR if preferred.
cc @smartalecH @stevengj