Skip to content

Tutorial: Periodic Unit Cell Analysis

This tutorial covers analyzing periodic structures (metasurfaces, frequency selective surfaces, phased array unit cells) with EdgeFEM. You'll learn to:

  • Create unit cell geometry with periodic boundaries
  • Compute reflection and transmission coefficients
  • Analyze oblique incidence behavior
  • Extract surface impedance and effective parameters
  • Study scan angle effects for phased arrays

Background

Periodic structures are fundamental building blocks for:

  • Metasurfaces: Control wave phase, polarization, absorption
  • Frequency Selective Surfaces (FSS): Bandpass/bandstop filters
  • Phased Arrays: Unit cell characterizes element coupling
  • Metamaterials: Engineered effective ε and μ

EdgeFEM uses Floquet boundary conditions to simulate an infinite periodic array from a single unit cell.

Key concepts:

  • Floquet theorem: Fields repeat with phase shift across unit cell
  • Phase shift: \(\phi = k_0 d \sin\theta\) for incidence angle θ
  • Normal incidence: θ = 0, no phase shift between cells

Step 1: Create Unit Cell

Design a simple square patch unit cell for 10 GHz operation:

from edgefem.designs import UnitCellDesign
import numpy as np

# Unit cell parameters
period = 5e-3        # 5mm period (λ/6 at 10 GHz)
h_sub = 0.5e-3       # 0.5mm substrate
eps_r = 3.5          # Low-loss dielectric

# Create unit cell with ground plane (reflective)
cell = UnitCellDesign(
    period_x=period,
    period_y=period,
    substrate_height=h_sub,
    substrate_eps_r=eps_r,
    has_ground_plane=True,  # Reflective metasurface
)

print(f"Unit cell: {period*1000:.1f} x {period*1000:.1f} mm")
print(f"Substrate: h={h_sub*1000:.2f}mm, εr={eps_r}")

Step 2: Add Patch Element

Add a square patch to the unit cell:

# Square patch (80% fill factor)
patch_size = 0.8 * period  # 4mm

cell.add_patch(
    width=patch_size,
    length=patch_size,
    center_x=0,    # Centered
    center_y=0,
    rotation=0,    # No rotation
    layer="top"    # On substrate top surface
)

print(f"Patch size: {patch_size*1000:.1f} x {patch_size*1000:.1f} mm")

Step 3: Generate Mesh

# Generate mesh with periodic boundaries
cell.generate_mesh(
    density=20,              # Elements per wavelength
    design_freq=10e9,        # Design frequency for mesh sizing
    output_path="unit_cell"  # Save mesh for inspection
)

print(f"Mesh generated: {len(cell.mesh.elements)} elements")

Step 4: Normal Incidence Analysis

Compute reflection coefficient at normal incidence:

# Normal incidence (θ=0, φ=0)
freq = 10e9
R, T = cell.reflection_transmission(freq, theta=0, phi=0, pol='TE')

# For grounded structure, T ≈ 0
print(f"\nNormal incidence at {freq/1e9:.1f} GHz:")
print(f"  Reflection: R = {R:.4f}")
print(f"  |R| = {abs(R):.4f} ({20*np.log10(abs(R)+1e-10):.1f} dB)")
print(f"  Phase(R) = {np.angle(R)*180/np.pi:.1f}°")

Step 5: Frequency Sweep

Study frequency-dependent behavior:

# Frequency sweep
freqs, R_array, T_array = cell.frequency_sweep_reflection(
    f_start=5e9,
    f_stop=15e9,
    n_points=51,
    theta=0,
    phi=0,
    pol='TE',
    verbose=True
)

# Find resonance (minimum reflection or phase crossing)
R_mag = np.abs(R_array)
idx_min = np.argmin(R_mag)
f_resonance = freqs[idx_min]

print(f"\nResonance: {f_resonance/1e9:.2f} GHz")
print(f"  |R|_min = {R_mag[idx_min]:.4f}")

Plot the results:

import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 1, figsize=(10, 8))

# Magnitude
axes[0].plot(freqs/1e9, 20*np.log10(R_mag), 'b-', linewidth=2)
axes[0].set_xlabel('Frequency (GHz)')
axes[0].set_ylabel('|R| (dB)')
axes[0].set_title('Unit Cell Reflection Magnitude')
axes[0].grid(True)
axes[0].axvline(x=f_resonance/1e9, color='r', linestyle='--', label='Resonance')
axes[0].legend()

# Phase
R_phase = np.angle(R_array) * 180 / np.pi
axes[1].plot(freqs/1e9, R_phase, 'b-', linewidth=2)
axes[1].set_xlabel('Frequency (GHz)')
axes[1].set_ylabel('Phase(R) (deg)')
axes[1].set_title('Reflection Phase')
axes[1].grid(True)
axes[1].axhline(y=0, color='r', linestyle='--')
axes[1].axhline(y=-180, color='g', linestyle=':')

plt.tight_layout()
plt.savefig('unit_cell_reflection.png', dpi=150)
plt.show()

Step 6: Surface Impedance

Extract surface impedance from reflection:

# Surface impedance at resonance
Zs = cell.surface_impedance(f_resonance)

print(f"\nSurface impedance at {f_resonance/1e9:.2f} GHz:")
print(f"  Zs = {Zs.real:.1f} + j{Zs.imag:.1f} Ω")

# For reference, free-space impedance
Z0 = 376.73  # Ω
print(f"  Normalized: Zs/Z0 = {(Zs/Z0).real:.3f} + j{(Zs/Z0).imag:.3f}")

Surface impedance characterization:

Zs Behavior Metasurface Type
Re(Zs) >> Z0 High-impedance surface (HIS)
Re(Zs) << Z0 Low-impedance (conductor-like)
Im(Zs) = 0 Resonance condition
Im(Zs) > 0 Inductive
Im(Zs) < 0 Capacitive

Step 7: Oblique Incidence

Analyze behavior vs. incidence angle:

# Scan angle sweep at resonant frequency
angles = np.arange(0, 61, 5)  # 0 to 60 degrees
R_vs_angle_TE = []
R_vs_angle_TM = []

for theta in angles:
    R_te, _ = cell.reflection_transmission(f_resonance, theta=theta, phi=0, pol='TE')
    R_tm, _ = cell.reflection_transmission(f_resonance, theta=theta, phi=0, pol='TM')
    R_vs_angle_TE.append(R_te)
    R_vs_angle_TM.append(R_tm)

R_vs_angle_TE = np.array(R_vs_angle_TE)
R_vs_angle_TM = np.array(R_vs_angle_TM)

Plot angular dependence:

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Magnitude vs angle
axes[0].plot(angles, np.abs(R_vs_angle_TE), 'b-o', label='TE')
axes[0].plot(angles, np.abs(R_vs_angle_TM), 'r--s', label='TM')
axes[0].set_xlabel('Incidence Angle (deg)')
axes[0].set_ylabel('|R|')
axes[0].set_title(f'Reflection Magnitude at {f_resonance/1e9:.1f} GHz')
axes[0].legend()
axes[0].grid(True)

# Phase vs angle
axes[1].plot(angles, np.angle(R_vs_angle_TE)*180/np.pi, 'b-o', label='TE')
axes[1].plot(angles, np.angle(R_vs_angle_TM)*180/np.pi, 'r--s', label='TM')
axes[1].set_xlabel('Incidence Angle (deg)')
axes[1].set_ylabel('Phase(R) (deg)')
axes[1].set_title('Reflection Phase')
axes[1].legend()
axes[1].grid(True)

plt.tight_layout()
plt.savefig('unit_cell_oblique.png', dpi=150)
plt.show()

Step 8: FSS Configuration (Transmission)

For frequency selective surfaces that allow transmission, configure without ground plane:

# FSS unit cell (no ground plane)
fss = UnitCellDesign(
    period_x=5e-3,
    period_y=5e-3,
    substrate_height=0.5e-3,
    substrate_eps_r=3.5,
    has_ground_plane=False,      # No ground
    air_height_below=15e-3,      # Air region below
)

# Add patch
fss.add_patch(width=4e-3, length=4e-3)

# Generate mesh
fss.generate_mesh(density=20)

# Compute R and T
R, T = fss.reflection_transmission(10e9, theta=0, phi=0)

print(f"\nFSS at 10 GHz:")
print(f"  |R| = {abs(R):.4f} ({20*np.log10(abs(R)+1e-10):.1f} dB)")
print(f"  |T| = {abs(T):.4f} ({20*np.log10(abs(T)+1e-10):.1f} dB)")

# Energy conservation check
power = abs(R)**2 + abs(T)**2
print(f"  |R|² + |T|² = {power:.4f} (should be ≤1)")

Step 9: Effective Parameter Extraction

For FSS structures, extract effective ε and μ using NRW method:

if fss.is_fss:
    eps_eff, mu_eff = fss.effective_parameters(10e9)

    print(f"\nEffective parameters at 10 GHz:")
    print(f"  εeff = {eps_eff.real:.3f} + j{eps_eff.imag:.3f}")
    print(f"  μeff = {mu_eff.real:.3f} + j{mu_eff.imag:.3f}")

    # Refractive index
    n_eff = np.sqrt(eps_eff * mu_eff)
    print(f"  n_eff = {n_eff.real:.3f} + j{n_eff.imag:.3f}")

Step 10: Export Results

Touchstone Format

# Export reflection data
cell.export_touchstone("unit_cell_reflection", freqs, R_array)
print("Saved: unit_cell_reflection.s1p")

# For FSS with transmission
fss.export_touchstone("fss_sparams", freqs, R_array, T_array)
print("Saved: fss_sparams.s2p")

Advanced: Complex Unit Cell

Create a more complex unit cell with multiple elements:

# Jerusalem cross unit cell
jc = UnitCellDesign(
    period_x=10e-3,
    period_y=10e-3,
    substrate_height=1.0e-3,
    substrate_eps_r=2.2,
)

# Central cross (horizontal arm)
jc.add_patch(width=8e-3, length=2e-3, center_x=0, center_y=0)

# Vertical arm
jc.add_patch(width=2e-3, length=8e-3, center_x=0, center_y=0)

# End caps (makes it a Jerusalem cross)
jc.add_patch(width=2e-3, length=4e-3, center_x=-3e-3, center_y=0)
jc.add_patch(width=2e-3, length=4e-3, center_x=3e-3, center_y=0)

jc.generate_mesh(density=20)
print(f"Jerusalem cross: {len(jc._geometry_elements)} elements")

Phased Array Unit Cell Analysis

For phased array applications, analyze active impedance vs. scan:

from edgefem.designs import UnitCellDesign
import numpy as np

# Phased array unit cell
array_cell = UnitCellDesign(
    period_x=15e-3,      # ~λ/2 at 10 GHz
    period_y=15e-3,
    substrate_height=1.5e-3,
    substrate_eps_r=3.0,
)

# Patch element
array_cell.add_patch(width=10e-3, length=10e-3)
array_cell.generate_mesh(density=15)

# Active impedance vs. E-plane scan
scan_angles = np.arange(0, 71, 5)
Z_active = []

for theta in scan_angles:
    Zs = array_cell.surface_impedance(10e9, theta=theta, phi=0, pol='TE')
    Z_active.append(Zs)

Z_active = np.array(Z_active)

# Plot active impedance
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(scan_angles, Z_active.real, 'b-o', label='Re(Z_active)')
ax.plot(scan_angles, Z_active.imag, 'r--s', label='Im(Z_active)')
ax.set_xlabel('Scan Angle (deg)')
ax.set_ylabel('Impedance (Ω)')
ax.set_title('Active Element Impedance vs. Scan Angle')
ax.legend()
ax.grid(True)
plt.savefig('active_impedance_scan.png', dpi=150)
plt.show()

# Find scan blindness (impedance anomaly)
Z_mag = np.abs(Z_active)
if np.max(Z_mag) > 5 * np.min(Z_mag):
    idx_blind = np.argmax(Z_mag)
    print(f"Potential scan blindness at θ = {scan_angles[idx_blind]}°")

Design Guidelines

Unit Cell Size

Application Period Notes
Metasurface < λ/5 Subwavelength for effective medium
FSS λ/3 - λ/2 Resonant behavior
Phased array ~λ/2 Grating lobe avoidance

Element Geometry

Type Behavior Application
Patch Resonant, capacitive below Absorber, HIS
Slot Resonant, inductive below FSS, AMC
Cross Dual-polarization Polarizer
Ring Multiple resonances Multi-band

Substrate Effects

  • Higher εr → narrower bandwidth, smaller cell
  • Thicker substrate → stronger resonance, bandwidth
  • Loss tangent → absorption, Q reduction

Troubleshooting

Non-physical results (|R| > 1)

  • Increase mesh density
  • Check periodic boundary matching
  • Verify element fits within unit cell

No resonance visible

  • Element may be too small/large for frequency
  • Check element is properly meshed
  • Verify substrate properties

TE/TM results identical

  • At normal incidence, TE and TM should match for symmetric cells
  • Check polarization definition for oblique incidence

Complete Script

"""
Complete Unit Cell Analysis Script
"""
from edgefem.designs import UnitCellDesign
import numpy as np
import matplotlib.pyplot as plt

# Create unit cell
cell = UnitCellDesign(
    period_x=5e-3,
    period_y=5e-3,
    substrate_height=0.5e-3,
    substrate_eps_r=3.5,
)

cell.add_patch(width=4e-3, length=4e-3)
cell.generate_mesh(density=20)

print(f"Unit cell: {cell.period_x*1000:.1f} x {cell.period_y*1000:.1f} mm")

# Frequency sweep
freqs, R_arr, _ = cell.frequency_sweep_reflection(
    5e9, 15e9, n_points=51, verbose=True
)

# Find resonance
idx_res = np.argmin(np.abs(R_arr))
f_res = freqs[idx_res]
print(f"\nResonance: {f_res/1e9:.2f} GHz")

# Surface impedance
Zs = cell.surface_impedance(f_res)
print(f"Surface impedance: {Zs.real:.1f}+j{Zs.imag:.1f} Ω")

# Oblique incidence
angles = np.arange(0, 61, 10)
R_vs_theta = [cell.reflection_transmission(f_res, theta=a)[0] for a in angles]

# Plot
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# Magnitude vs freq
axes[0,0].plot(freqs/1e9, 20*np.log10(np.abs(R_arr)+1e-10))
axes[0,0].set_xlabel('Frequency (GHz)')
axes[0,0].set_ylabel('|R| (dB)')
axes[0,0].set_title('Reflection vs Frequency')
axes[0,0].grid(True)

# Phase vs freq
axes[0,1].plot(freqs/1e9, np.angle(R_arr)*180/np.pi)
axes[0,1].set_xlabel('Frequency (GHz)')
axes[0,1].set_ylabel('Phase(R) (deg)')
axes[0,1].set_title('Phase vs Frequency')
axes[0,1].grid(True)

# |R| vs angle
axes[1,0].plot(angles, np.abs(R_vs_theta), 'o-')
axes[1,0].set_xlabel('Incidence Angle (deg)')
axes[1,0].set_ylabel('|R|')
axes[1,0].set_title(f'Reflection vs Angle at {f_res/1e9:.1f} GHz')
axes[1,0].grid(True)

# Phase vs angle
axes[1,1].plot(angles, np.angle(R_vs_theta)*180/np.pi, 'o-')
axes[1,1].set_xlabel('Incidence Angle (deg)')
axes[1,1].set_ylabel('Phase(R) (deg)')
axes[1,1].set_title('Phase vs Angle')
axes[1,1].grid(True)

plt.tight_layout()
plt.savefig('unit_cell_complete.png', dpi=150)
plt.show()

# Export
cell.export_touchstone("unit_cell", freqs, R_arr)
print("\nSaved: unit_cell.s1p, unit_cell_complete.png")

Next Steps