|
| 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