L shaped antenna simulation.#

In this notebook we will generate the mesh of an L shaped antenna and generate a json for a transient simulation.

import gmsh
import os
import json

from palacetoolkit.viz import view_mesh
from palacetoolkit.mesh import (
    Entity, 
    run_meshing_pipeline, 
    generate_3d_mesh, 
    refine_near_surfaces
)

Parameters:#

  • h : Patch height along z-axis, specified as a scalar in meters.

  • l1 : Ground plane length along x-axis, specified as a scalar in meters

  • w1 : Ground plane width along y-axis, specified as a scalar in meters

  • w3 : Strip line width along y-axis, specified as a scalar in meters.

  • air_height : Air box height along z-axis, specified as a scalar in meters.

  • air_margin : Air box margin along x and y axes, specified as a scalar in meters.

  • freq : Simulation frequency in GHz, specified as a scalar.

  • filename : Output mesh filename, specified as a string.

l1: float = 0.06
w1: float = 0.06
w3: float = 0.002
h: float = 0.0013
air_height: float = 0.025    
air_margin: float = 0.025    
freq: float = 3.3
mesh_file: str = "l_antenna.msh"


eps_r: float = 2.2
loss_tan: float = 0.0009

wavelength = 3e8 / (freq * 1e9)
mesh_size = wavelength / 15

Initialize the model#

gmsh.initialize()
gmsh.model.add("patch_antenna")
kernel = gmsh.model.occ

Geometry Construction#

total_xmin = -l1/2 - air_margin
total_xmax = l1/2 + air_margin
total_ymin = -w1/2 - air_margin
total_ymax = w1/2 + air_margin
total_zmax = h + air_height


# 1. Create volumes
substrate = kernel.addBox(-l1/2, -w1/2, 0, l1, w1, h)
airsphere_radius = max(abs(total_xmin), abs(total_xmax), abs(total_ymin), abs(total_ymax), total_zmax)
air_sphere = kernel.addSphere(0.0, 0.0, 0.0, airsphere_radius)
kernel.synchronize()

# 2. Create 2D surfaces (ground, patch, ports)
ground_plane = kernel.addRectangle(-l1/2, -w1/2, 0, l1, w1)

strip_length_x = l1/2
strip_length_y = w1/2 + w3/2

feed_line_x = kernel.addRectangle(-l1/2, -w3/2, h, strip_length_x, w3)
feed_line_y = kernel.addRectangle(0, -w3/2, h, w3, strip_length_y)

top_conductor, top_map = kernel.fuse(
    [(2, feed_line_x)], [(2, feed_line_y)],
    removeObject=True, removeTool=True
)
kernel.synchronize()

# Lumped ports - build directly in-plane, no rotation
# Port 1: at x = -l1/2, vertical face
p1 = kernel.addPoint(-l1/2, -w3/2, 0)
p2 = kernel.addPoint(-l1/2,  w3/2, 0)
p3 = kernel.addPoint(-l1/2,  w3/2, h)
p4 = kernel.addPoint(-l1/2, -w3/2, h)

lp1_a = kernel.addLine(p1, p2)
lp1_b = kernel.addLine(p2, p3)
lp1_c = kernel.addLine(p3, p4)
lp1_d = kernel.addLine(p4, p1)
loop1 = kernel.addCurveLoop([lp1_a, lp1_b, lp1_c, lp1_d])
lumped_port_1 = kernel.addPlaneSurface([loop1])
kernel.synchronize()

# Port 2: at y = w1/2, vertical face
p5 = kernel.addPoint(0,  w1/2, 0)
p6 = kernel.addPoint(w3, w1/2, 0)
p7 = kernel.addPoint(w3, w1/2, h)
p8 = kernel.addPoint(0,  w1/2, h)

lp2_a = kernel.addLine(p5, p6)
lp2_b = kernel.addLine(p6, p7)
lp2_c = kernel.addLine(p7, p8)
lp2_d = kernel.addLine(p8, p5)
loop2 = kernel.addCurveLoop([lp2_a, lp2_b, lp2_c, lp2_d])
lumped_port_2 = kernel.addPlaneSurface([loop2])
kernel.synchronize()

# Define the entities which will be the physical groups.
entities = [
    Entity("substrate", dim=3, btype="dielectric", mesh_order=1, tags=[substrate], loss_tan=loss_tan, eps_r=eps_r, mu_r=1.0),
    Entity("air_sphere", dim=3, btype="dielectric", mesh_order=2, tags=[air_sphere], loss_tan=0.0, eps_r=1.0, mu_r=1.0),
    Entity("top_conductor", dim=2, btype="pec", mesh_order=1, tags=[top_conductor[0][1]]),
    Entity("ground_plane", dim=2, btype="pec", mesh_order=1, tags=[ground_plane]),
    Entity("lumped_port_1", dim=2, btype="lumped_port", mesh_order=0, tags=[lumped_port_1], R=50.0, direction="+Z", excitation=True),
    Entity("lumped_port_2", dim=2, btype="lumped_port", mesh_order=0, tags=[lumped_port_2], R=50.0, direction="+Z", excitation=False),
]
Info    : Cannot bind existing OpenCASCADE surface 9 to second tag 10                                                                       
Info    : Could not preserve tag of 2D object 10 (->9)
pg_map = run_meshing_pipeline(entities)

lumped_port_1 = entities[-1].dimtags[0]
lumped_port_2 =  entities[-2].dimtags[0]

# Refine near the top conductor and also locally refine the ports.
refine_near_surfaces(entities[2].dimtags, 
                     wavelength, 
                     ppw_near=100, 
                     ppw_far=15, 
                     set_as_background=True,
                     local_refinements = {lumped_port_1: 100, lumped_port_2 : 100})

mesh_sizes = {
    "substrate": wavelength / 12,
    "air_sphere": wavelength / 4,
    "lumped_port_1": wavelength / 18,
    "lumped_port_2": wavelength / 18,
    "top_conductor": wavelength /10
}

generate_3d_mesh(entities, mesh_sizes, mesh_file, optimize=True, verbose=False)
gmsh.finalize()
  Physical group 'substrate' (dim=3): pg=1, tags=[1]                                                                                             
  Physical group 'air_sphere' (dim=3): pg=2, tags=[2]
  Physical group 'top_conductor' (dim=2): pg=3, tags=[9]
  Physical group 'ground_plane' (dim=2): pg=4, tags=[8]
  Physical group 'lumped_port_1' (dim=2): pg=5, tags=[10]
  Physical group 'lumped_port_2' (dim=2): pg=6, tags=[11]
  Physical group 'air_sphere__substrate' (dim=2): pg=7, tags=[12, 13, 14, 15, 16, 17, 18, 19]
  Physical group 'air_sphere__None' (dim=2): pg=8, tags=[20]
  ppw_near=100  ppw_far=15
  SizeMax=0.0061  transition=0.0227
  global: 6 curves, SizeMin=0.0009
  local (2, 11): 4 curves, SizeMin=0.0009
  local (2, 10): 4 curves, SizeMin=0.0009
  Merged 3 fields with Min → field 7
view_mesh(mesh_file, transparent_groups= "air_sphere__None")
Loading mesh file: l_antenna.msh
Groups to render transparent: air_sphere__None

Mesh loaded successfully with 2 cell blocks
Found 5922 triangles total
Physical group tags in mesh: {3: 'top_conductor', 4: 'ground_plane', 5: 'lumped_port_1', 6: 'lumped_port_2', 7: 'air_sphere__substrate', 8: 'air_sphere__None'}

Simulation Configuration#

We define the key parameters for the electromagnetic simulation here. These settings control the frequency sweep range, material properties (dielectric constant and loss tangent for the substrate), and solver-specific configurations like port impedance and mesh order.

  • output_file : output filename for the configuration JSON file

  • freq : frequency for the simulation (GHz)

  • eps_r: relative permittivity of the substrate

  • loss_tan: loss tangent of the substrate

  • port_impedance: characteristic impedance of the lumped port (Ohms)

  • solver_order: order of the finite element basis functions for the simulation (e.g., 1 for linear, 2 for quadratic)

output_file_transient: str = "l_antenna_transient.json"
output_file_driven: str = "l_antenna_driven.json"

freq: float = 3.16
eps_r: float = 2.2
loss_tan: float = 0.0009
port_impedance: float = 50.0
solver_order: int = 2


import numpy as np
eps_0 = 8.8541878128e-12
sigma = 2 * np.pi * freq * eps_0 * eps_r * loss_tan

Generating the Palace Configuration File#

Finally, we assemble the simulation parameters into two JSON configuration. One for a transient simulation and one for a driven where we check the s-parameters.

def attr(name):
        return [pg_map[name]] if name in pg_map else []

config = {
    "Problem": {
        "Type": "Transient",
        "Verbose": 2,
        "Output": "/work/results_transient/l_antenna/"
    },

    "Model": {
        "Mesh": f"/work/{mesh_file}",
        "L0": 1.0,
        "Refinement": {}
    },

    "Domains": {
    "Materials": [
        {
            "Attributes": attr("substrate"),
            "Permittivity": eps_r,
            "Permeability": 1.0,
            "Conductivity": sigma  # Replaced LossTan
        },
        {
            "Attributes": attr("air"),
            "Permittivity": 1.0,
            "Permeability": 1.0
        }
    ]
},

    "Boundaries": {
        "PEC": {
            "Attributes": attr("ground_plane") + attr("patch")
        },

        "LumpedPort": [
            {
                "Index": 1,
                "Attributes": attr("lumped_port_1"),
                "R": port_impedance,
                "Excitation": True,
                "Direction":  [0.0, 0.0, 1.0]
            },
            {
                "Index": 2,
                "Attributes": attr("lumped_port_2"),
                "R": port_impedance,
                "Excitation": False,              
                "Direction": [0.0, 0.0, 1.0]     
            }
        ],

        "Absorbing": {
            "Attributes": attr("farfield"),
            "Order": 1
        }
    },

    "Solver": {
    "Order": solver_order,
    "Device": "CPU",
    "Transient": {
      "Type": "GeneralizedAlpha",
      "Excitation": "ModulatedGaussian",
      "ExcitationFreq": freq, 
      "ExcitationWidth": 0.05, 
      "MaxTime": 1.0, 
      "TimeStep": 0.005, 
      "SaveStep": 10
    },
    "Linear": {
      "Type": "AMS",
      "KSPType": "CG",
      "Tol": 1.0e-8,
      "MaxIts": 100
    }
  }
}



script_dir = os.getcwd()
config_path = os.path.join(script_dir, output_file_transient)
with open(config_path, "w") as f:
    json.dump(config, f, indent=2)
print(f"Palace config written to {config_path}")
Palace config written to /home/martin/Desktop/PalaceToolkit/docs/examples/l_antenna_transient.json
config = {
    "Problem": {
        "Type": "Driven",
        "Verbose": 2,
        "Output": "/work/results_driven/l_antenna/"
    },

    "Model": {
        "Mesh": f"/work/{mesh_file}",
        "L0": 1.0,
        "Refinement": {}
    },

    "Domains": {
    "Materials": [
        {
            "Attributes": attr("substrate"),
            "Permittivity": eps_r,
            "Permeability": 1.0,
            "Conductivity": loss_tan 
        },
        {
            "Attributes": attr("air"),
            "Permittivity": 1.0,
            "Permeability": 1.0
        }
    ]
},

    "Boundaries": {
        "PEC": {
            "Attributes": attr("ground_plane") + attr("patch")
        },

        "LumpedPort": [
            {
                "Index": 1,
                "Attributes": attr("lumped_port_1"),
                "R": port_impedance,
                "Excitation": True,
                "Direction":  [0.0, 0.0, 1.0]
            },
            {
                "Index": 2,
                "Attributes": attr("lumped_port_2"),
                "R": port_impedance,
                "Excitation": False,              
                "Direction": [0.0, 0.0, 1.0]     
            }
        ],

        "Absorbing": {
            "Attributes": attr("farfield"),
            "Order": 1
        }
    },

  "Solver": {
    "Order": 2,
    "Device": "CPU",
    "Driven": {
      "MinFreq": 3.0,
      "MaxFreq": 3.5,
      "FreqStep": 0.1,
      "SaveStep": 1,
      "AdaptiveTol": 0.0001
    },
    "Linear": {
      "Type": "Default",
      "KSPType": "GMRES",
      "Tol": 1e-08,
      "MaxIts": 300,
      "MaxSize": 1000,
      "ComplexCoarseSolve": True
    }
  }
}



script_dir = os.getcwd()
config_path = os.path.join(script_dir, output_file_driven)
with open(config_path, "w") as f:
    json.dump(config, f, indent=2)
print(f"Palace config written to {config_path}")
Palace config written to /home/martin/Desktop/PalaceToolkit/docs/examples/l_antenna_driven.json