Skip to content

Commit 2986f34

Browse files
committed
Add sphere convergence test: mesh icosphere vs native Sphere
Verifies that a mesh icosphere converges to meep's native Sphere primitive as subdivision increases. L2 error decreases monotonically at ~2x per subdivision level, with mismatched pixels dropping from 2276 (20 faces) to 12 (5120 faces).
1 parent b14367f commit 2986f34

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

utils/test_sphere_convergence.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Convergence test: mesh icosphere vs native Sphere.
2+
3+
Compares the epsilon grid of a mesh icosphere against meep's native
4+
Sphere at increasing resolution. Verifies that the mesh converges
5+
to the same geometry as the analytic primitive.
6+
"""
7+
import meep as mp
8+
import numpy as np
9+
import math
10+
11+
def make_icosphere(radius, subdivisions, center=mp.Vector3(0, 0, 0)):
12+
"""Create an icosphere mesh by subdividing an icosahedron."""
13+
# Golden ratio
14+
phi = (1 + math.sqrt(5)) / 2
15+
16+
# Icosahedron vertices (normalized to unit sphere, then scaled)
17+
raw_verts = [
18+
(-1, phi, 0), (1, phi, 0), (-1, -phi, 0), (1, -phi, 0),
19+
(0, -1, phi), (0, 1, phi), (0, -1, -phi), (0, 1, -phi),
20+
(phi, 0, -1), (phi, 0, 1), (-phi, 0, -1), (-phi, 0, 1),
21+
]
22+
verts = []
23+
for v in raw_verts:
24+
norm = math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
25+
verts.append((v[0]/norm * radius, v[1]/norm * radius, v[2]/norm * radius))
26+
27+
tris = [
28+
(0,11,5), (0,5,1), (0,1,7), (0,7,10), (0,10,11),
29+
(1,5,9), (5,11,4), (11,10,2), (10,7,6), (7,1,8),
30+
(3,9,4), (3,4,2), (3,2,6), (3,6,8), (3,8,9),
31+
(4,9,5), (2,4,11), (6,2,10), (8,6,7), (9,8,1),
32+
]
33+
34+
# Subdivide
35+
for _ in range(subdivisions):
36+
edge_midpoints = {}
37+
new_tris = []
38+
for i0, i1, i2 in tris:
39+
mids = []
40+
for ea, eb in [(i0,i1), (i1,i2), (i2,i0)]:
41+
edge = (min(ea,eb), max(ea,eb))
42+
if edge not in edge_midpoints:
43+
va, vb = verts[ea], verts[eb]
44+
mx = (va[0]+vb[0])/2
45+
my = (va[1]+vb[1])/2
46+
mz = (va[2]+vb[2])/2
47+
norm = math.sqrt(mx*mx + my*my + mz*mz)
48+
edge_midpoints[edge] = len(verts)
49+
verts.append((mx/norm*radius, my/norm*radius, mz/norm*radius))
50+
mids.append(edge_midpoints[edge])
51+
m01, m12, m20 = mids
52+
new_tris.extend([
53+
(i0, m01, m20), (i1, m12, m01),
54+
(i2, m20, m12), (m01, m12, m20),
55+
])
56+
tris = new_tris
57+
58+
return mp.Mesh(
59+
vertices=[mp.Vector3(*v) for v in verts],
60+
triangles=tris,
61+
center=center,
62+
material=mp.Medium(epsilon=12),
63+
)
64+
65+
66+
def get_eps_slice(geometry, cell_size, resolution, n_sample=100):
67+
"""Get a 2D epsilon slice at z=0."""
68+
sim = mp.Simulation(
69+
cell_size=cell_size,
70+
geometry=geometry,
71+
resolution=resolution,
72+
dimensions=3,
73+
eps_averaging=False,
74+
)
75+
sim.init_sim()
76+
xtics = np.linspace(-cell_size.x/2, cell_size.x/2, n_sample)
77+
ytics = np.linspace(-cell_size.y/2, cell_size.y/2, n_sample)
78+
ztics = np.array([0.0])
79+
eps = np.real(sim.get_epsilon_grid(xtics, ytics, ztics)).reshape(n_sample, n_sample)
80+
return eps
81+
82+
83+
# Parameters
84+
radius = 0.4
85+
cell_size = mp.Vector3(1.5, 1.5, 1.5)
86+
n_sample = 200
87+
88+
# Reference: native sphere
89+
print("=== Native Sphere (reference) ===", flush=True)
90+
sphere_geom = [mp.Sphere(radius=radius, center=mp.Vector3(0,0,0),
91+
material=mp.Medium(epsilon=12))]
92+
eps_sphere = get_eps_slice(sphere_geom, cell_size, resolution=4, n_sample=n_sample)
93+
94+
# Mesh spheres at increasing subdivision
95+
print("\n=== Mesh Icosphere convergence ===", flush=True)
96+
print(f"{'subdivs':>8} {'verts':>8} {'faces':>8} {'L2 error':>12} {'max error':>12}", flush=True)
97+
98+
errors = []
99+
for subdivs in range(5):
100+
mesh_sphere = make_icosphere(radius, subdivs)
101+
nv = len(mesh_sphere.vertices)
102+
nf = len(mesh_sphere.triangles)
103+
104+
eps_mesh = get_eps_slice([mesh_sphere], cell_size, resolution=4, n_sample=n_sample)
105+
106+
# Compare against native sphere
107+
diff = np.abs(eps_mesh - eps_sphere)
108+
l2_err = np.sqrt(np.mean(diff**2))
109+
max_err = np.max(diff)
110+
mismatch = np.sum(np.abs(eps_mesh - eps_sphere) > 0.5)
111+
112+
print(f"{subdivs:>8} {nv:>8} {nf:>8} {l2_err:>12.6f} {max_err:>12.6f} ({mismatch} mismatched pixels)", flush=True)
113+
errors.append((nv, nf, l2_err, mismatch))
114+
115+
# Check convergence: error should decrease with subdivision
116+
print("\n=== Convergence check ===", flush=True)
117+
for i in range(1, len(errors)):
118+
ratio = errors[i-1][2] / errors[i][2] if errors[i][2] > 0 else float('inf')
119+
print(f" subdivs {i-1}->{i}: L2 error ratio = {ratio:.2f}x", flush=True)
120+
121+
# Check: error should decrease monotonically and mismatched pixels should shrink
122+
monotonic = all(errors[i][2] < errors[i-1][2] for i in range(1, len(errors)))
123+
final_mismatches = errors[-1][3]
124+
if monotonic and final_mismatches < 50:
125+
print(f"\nPASS: L2 error converging monotonically, {final_mismatches} mismatched pixels at highest subdivision", flush=True)
126+
else:
127+
print(f"\nFAIL: monotonic={monotonic}, final mismatches={final_mismatches}", flush=True)

0 commit comments

Comments
 (0)