From c9bb753630321a0d85cc63a04300e25d03b968e5 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Tue, 21 Jul 2026 19:12:21 -0300 Subject: [PATCH 1/6] feat: TW-MZM cross-section with PN junction, CPW electrodes, and graded doping --- nbs/_palace_2d_twmzm.ipynb | 677 +++++++++++++++++++++++ nbs/_palace_2d_twmzm.py | 564 +++++++++++++++++++ pyproject.toml | 2 +- src/gsim/palace/__init__.py | 5 +- src/gsim/palace/base.py | 27 +- src/gsim/palace/mesh/config_generator.py | 9 + src/gsim/palace/mesh/generator.py | 120 +++- src/gsim/palace/runtime.py | 31 +- src/gsim/viz.py | 30 +- 9 files changed, 1443 insertions(+), 22 deletions(-) create mode 100644 nbs/_palace_2d_twmzm.ipynb create mode 100644 nbs/_palace_2d_twmzm.py diff --git a/nbs/_palace_2d_twmzm.ipynb b/nbs/_palace_2d_twmzm.ipynb new file mode 100644 index 00000000..a7b70284 --- /dev/null +++ b/nbs/_palace_2d_twmzm.ipynb @@ -0,0 +1,677 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator\n", + "\n", + "This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation.\n", + "\n", + "**Cross-section geometry (from literature):**\n", + "- **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX)\n", + "- **Rib waveguide**: 400 nm width, 90 nm slab height\n", + "- **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um\n", + "- **PN junction**: Centred in the rib with P+/N+ contact regions in the slab\n", + "\n", + "We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes.\n", + "\n", + "**Requirements:**\n", + "- gdsfactory + generic PDK (`gf.gpdk`)\n", + "- [GDSFactory+](https://gdsfactory.com) account (for cloud Palace runs)" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### Build TW-MZM cross-section geometry\n", + "\n", + "We define the 3D layout component that will be sliced at $x=0$ for 2D mode analysis.\n", + "\n", + "**Layer assignments (gpdk):**\n", + "- `WG` (1,0): Rib waveguide core (220 nm Si, 400 nm wide)\n", + "- `SLAB90` (3,0): 90 nm slab regions\n", + "- `N` (20,0) / `P` (21,0): PN junction doping\n", + "- `NPP` (24,0) / `PP` (25,0): P+/N+ contact doping\n", + "- `M3` (49,0): CPW electrodes (Al, 1 um thick)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import gdsfactory as gf\n", + "\n", + "gf.gpdk.PDK.activate()\n", + "\n", + "RIB_WIDTH = 0.4\n", + "SLAB_HALF = 20.0\n", + "SIG_WIDTH = 20.0\n", + "GAP_WIDTH = 20.0\n", + "GND_WIDTH = 40.0\n", + "TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH\n", + "LENGTH = 10.0\n", + "\n", + "LAYER = gf.gpdk.LAYER\n", + "\n", + "\n", + "def centered_rect(wx: float, wy: float, layer) -> gf.Component:\n", + " r = gf.Component()\n", + " r << gf.c.rectangle((wx, wy), centered=True, layer=layer)\n", + " return r\n", + "\n", + "\n", + "# =============================================================================\n", + "# Unified helper: contiguous doping profile + materials + layer specs\n", + "# =============================================================================\n", + "def add_doping_profile(\n", + " comp: gf.Component,\n", + " length: float,\n", + " rib_center_y: float,\n", + " rib_width: float,\n", + " profile: dict[str, list[tuple[float, float]]],\n", + " permittivity: float = 11.9,\n", + " slab_zmax: float = 0.09,\n", + ") -> dict:\n", + " \"\"\"\n", + " Create contiguous doping regions on both sides of the rib and generate\n", + " the corresponding MaterialProperties and Layer definitions.\n", + "\n", + " profile = {\n", + " \"upper\": [(width_um, conductivity_S_per_m), ...], # P side (toward S)\n", + " \"lower\": [(width_um, conductivity_S_per_m), ...], # N side (toward G)\n", + " }\n", + "\n", + " Regions are placed contiguously (no gaps) starting from the rib edge.\n", + "\n", + " Returns: {\n", + " \"layer_specs\": {name: Layer, ...},\n", + " \"materials\": {name: MaterialProperties, ...},\n", + " \"centres\": {side: [y_centre, ...]},\n", + " }\n", + " \"\"\"\n", + " from gsim.common.stack.extractor import Layer\n", + " from gsim.common.stack.materials import (\n", + " DispersionModel,\n", + " MaterialProperties,\n", + " ValidityRange,\n", + " )\n", + "\n", + " side_config = {\n", + " \"upper\": {\"base_layer\": (23, 0), \"prefix\": \"pp_slab_\", \"sign\": 1},\n", + " \"lower\": {\"base_layer\": (24, 0), \"prefix\": \"npp_slab_\", \"sign\": -1},\n", + " }\n", + "\n", + " result = {\"layer_specs\": {}, \"materials\": {}, \"centres\": {}}\n", + "\n", + " for side in (\"upper\", \"lower\"):\n", + " cfg = side_config[side]\n", + " regions = profile.get(side, [])\n", + " sign = cfg[\"sign\"]\n", + " pos = rib_center_y + sign * rib_width / 2 # start at rib edge\n", + " centres = []\n", + "\n", + " for i, (width, sigma) in enumerate(regions):\n", + " name = f\"{cfg['prefix']}{i}\"\n", + " gds_layer = (cfg[\"base_layer\"][0], cfg[\"base_layer\"][1] + i)\n", + " centre = pos + sign * width / 2\n", + "\n", + " rect = comp << gf.c.rectangle((length, width), layer=gds_layer)\n", + " rect.y = centre\n", + " centres.append(centre)\n", + " pos += sign * width\n", + "\n", + " result[\"layer_specs\"][name] = Layer(\n", + " name=name,\n", + " gds_layer=gds_layer,\n", + " zmin=0.0,\n", + " zmax=slab_zmax,\n", + " thickness=slab_zmax,\n", + " material=name,\n", + " layer_type=\"dielectric\",\n", + " mesh_resolution=\"fine\",\n", + " )\n", + "\n", + " result[\"materials\"][name] = MaterialProperties(\n", + " permittivity=permittivity,\n", + " conductivity=sigma,\n", + " dispersion_models=[\n", + " DispersionModel(\n", + " type=\"constant\",\n", + " permittivity=permittivity,\n", + " validity=ValidityRange(valid_frequency=(0, 200e9)),\n", + " source=f\"doped Si ({name}) -- Drude sigma\",\n", + " ),\n", + " ],\n", + " )\n", + "\n", + " result[\"centres\"][side] = centres\n", + "\n", + " return result\n", + "\n", + "\n", + "# =============================================================================\n", + "# Component: TW-MZM cross-section\n", + "# =============================================================================\n", + "comp = gf.Component()\n", + "\n", + "# Rib centre: centred in the gap between left G (y approx -50) and centre S (y=0)\n", + "RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # -20.0\n", + "\n", + "# 1. Rib waveguide core\n", + "wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", + "wg.y = RIB_CENTER_Y\n", + "\n", + "# 2. Slab (90 nm)\n", + "slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", + "slab.y = RIB_CENTER_Y\n", + "\n", + "# 3. PN junction\n", + "p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", + "p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4\n", + "n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N)\n", + "n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4\n", + "\n", + "# 4+5. Doping gradient (contiguous, no gaps)\n", + "doping_profile = {\n", + " \"upper\": [ # P side (toward signal)\n", + " (2.0, 2.0e4), # width=2um, sigma=2e4 S/m\n", + " (2.0, 8.0e4), # width=2um, sigma=8e4 S/m\n", + " ],\n", + " \"lower\": [ # N side (toward ground)\n", + " (2.0, 2.0e4),\n", + " (2.0, 8.0e4),\n", + " ],\n", + "}\n", + "doping_result = add_doping_profile(\n", + " comp, LENGTH, RIB_CENTER_Y, RIB_WIDTH, doping_profile\n", + ")\n", + "\n", + "# 6. CPW electrodes (M1)\n", + "sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1)\n", + "gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + "gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2\n", + "gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + "gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2)\n", + "\n", + "# 7. Vias at x=0 (one per electrode, overlapping electrode)\n", + "via_s_to_p = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(2.7, 2.7)\n", + ")\n", + "via_s_to_p.x = 0.0\n", + "via_s_to_p.y = -9.0 # overlaps S electrode at y=-10\n", + "via_g_to_n = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(2.7, 2.7)\n", + ")\n", + "via_g_to_n.x = 0.0\n", + "via_g_to_n.y = -29.0 # overlaps G electrode at y=-30\n", + "\n", + "# -- Plot ----------------------------------------------------------------------\n", + "_cc = comp.copy()\n", + "_cc.draw_ports()\n", + "_cc.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "### Inspect 2D cross-section\n", + "\n", + "Before meshing, we extract the geometric cross-section at $x=0$ to verify that all layers are correctly defined." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "import gsim.common.cross_section as cross_section\n", + "from gsim.common.stack import get_stack\n", + "from gsim.common.stack.extractor import Layer\n", + "from gsim.common.stack.materials import (\n", + " DispersionModel,\n", + " MaterialProperties,\n", + " ValidityRange,\n", + ")\n", + "from gsim.palace import BoundaryModeSim\n", + "\n", + "# -- Stack --------------------------------------------------------------------\n", + "stack = get_stack(\n", + " substrate_thickness=2.0,\n", + " include_substrate=False,\n", + ")\n", + "\n", + "# Override metal1 thickness to 1 um (electrodes on M1)\n", + "m1 = stack.layers.get(\"metal1\")\n", + "if m1:\n", + " m1.zmax = 1.1 + 1.0\n", + " m1.thickness = 1.0\n", + " print(f\"M1 updated: zmin={m1.zmin}, zmax={m1.zmax}, thickness={m1.thickness}\")\n", + "\n", + "# -- Register doping materials and layers from the profile result -----\n", + "SI_RIB_ZMIN, SI_RIB_ZMAX = 0.0, 0.22\n", + "SI_SLAB_ZMAX = 0.09\n", + "\n", + "# doping_result comes from the geometry cell's add_doping_profile()\n", + "doped_materials = doping_result[\"materials\"]\n", + "doping_layers = doping_result[\"layer_specs\"]\n", + "\n", + "# Add the PN junction rib layers (not part of the gradient profile)\n", + "# PN junction (~1e19 cm^-3) -> sigma ~ 1600 S/m\n", + "for mat_name, gds_layer, zmax, sigma in [\n", + " (\"p_rib\", LAYER.P, SI_RIB_ZMAX, 1.6e3),\n", + " (\"n_rib\", LAYER.N, SI_RIB_ZMAX, 1.6e3),\n", + "]:\n", + " doping_layers[mat_name] = Layer(\n", + " name=mat_name,\n", + " gds_layer=gds_layer,\n", + " zmin=SI_RIB_ZMIN,\n", + " zmax=zmax,\n", + " thickness=zmax - SI_RIB_ZMIN,\n", + " material=mat_name,\n", + " layer_type=\"dielectric\",\n", + " mesh_resolution=\"fine\",\n", + " )\n", + " doped_materials[mat_name] = MaterialProperties(\n", + " permittivity=11.9,\n", + " conductivity=sigma,\n", + " dispersion_models=[\n", + " DispersionModel(\n", + " type=\"constant\",\n", + " permittivity=11.9,\n", + " validity=ValidityRange(valid_frequency=(0, 200e9)),\n", + " source=f\"doped Si ({mat_name}) -- Drude sigma\",\n", + " ),\n", + " ],\n", + " )\n", + "for name, layer in doping_layers.items():\n", + " stack.layers[name] = layer\n", + "\n", + "# -- Via layer definitions ----------------------------------------------------\n", + "# The gpdk already defines via_contact, via1, via2 in the stack. We\n", + "# ensure they are present and have the right z-extents for our cross-section.\n", + "# (They are already set correctly by the gpdk extractor, listed here for\n", + "# reference.)\n", + "print(f\"Stack: {stack.pdk_name}\")\n", + "print(\"Layers:\", sorted(stack.layers.keys()))\n", + "print(\"Custom materials:\", sorted(doped_materials.keys()))\n", + "print(\"Dielectrics:\", stack.dielectrics)\n", + "print()\n", + "\n", + "# -- Cross-section extraction ------------------------------------------------\n", + "section = cross_section.extract_plane_section(comp.copy(), stack, axis=\"x\", value=0.0)\n", + "print(f\"Cross-section x=0 intersects {len(section)} layer regions:\")\n", + "for r in section:\n", + " print(\n", + " f\" {r.layer_name:12s} material={r.material:10s} \"\n", + " f\"y=[{r.y0:8.3f}, {r.y1:8.3f}] z=[{r.zmin:6.3f}, {r.zmax:6.3f}]\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fedde670", + "metadata": {}, + "outputs": [], + "source": [ + "# -- Zoomed cross-section plot (rib region) ----------------------------------\n", + "# The full layout spans 140 um, so the 400 nm rib is invisible at full scale.\n", + "# Here we plot only the central ±15 um to show the rib, PN junction, and\n", + "# the P+/N+ ohmic contacts clearly.\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.patches import Rectangle\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 4))\n", + "\n", + "# Colour map per layer name\n", + "colors = {\n", + " \"core\": \"#c0392b\", # rib Si -- red\n", + " \"slab90\": \"#e67e22\", # slab Si -- orange\n", + " \"p_rib\": \"#2980b9\", # P doping -- blue\n", + " \"pp_slab_0\": \"#8e44ad\", # P+ graded inner -- purple\n", + " \"pp_slab_1\": \"#a569bd\", # P+ graded outer -- light purple\n", + " \"n_rib\": \"#27ae60\", # N doping -- green\n", + " \"npp_slab_0\": \"#4460ad\", # N+ graded inner -- indigo\n", + " \"npp_slab_1\": \"#5b7dcf\", # N+ graded outer -- light indigo\n", + " \"metal3\": \"#7f8c8d\", # Al -- grey\n", + " \"via_contact\": \"#f1c40f\", # via -- gold\n", + "}\n", + "\n", + "for r in section:\n", + " c = colors.get(r.layer_name, \"#dddddd\")\n", + " # RectYZ2D: y0/y1 are lateral, zmin/zmax are vertical\n", + " rect = Rectangle(\n", + " (r.y0, r.zmin),\n", + " r.y1 - r.y0,\n", + " r.zmax - r.zmin,\n", + " facecolor=c,\n", + " edgecolor=\"k\",\n", + " linewidth=0.5,\n", + " alpha=0.8,\n", + " label=r.layer_name,\n", + " )\n", + " ax.add_patch(rect)\n", + "\n", + "ax.set_xlim(-15, 15)\n", + "ax.set_ylim(-0.5, 5.0)\n", + "ax.set_aspect(\"equal\")\n", + "ax.set_xlabel(\"y (um)\")\n", + "ax.set_ylabel(\"z (um)\")\n", + "ax.set_title(\"TW-MZM cross-section (zoomed on rib + doping + signal electrode)\")\n", + "\n", + "# Deduplicate legend\n", + "handles, labels = ax.get_legend_handles_labels()\n", + "by_label = dict(zip(labels, handles))\n", + "ax.legend(by_label.values(), by_label.keys(), loc=\"upper right\", fontsize=8)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "# -- BoundaryMode 2D simulation setup ----------------------------------------\n", + "sim = BoundaryModeSim()\n", + "sim.set_output_dir(\"./palace-sim-mzm-pn\")\n", + "\n", + "# Custom stack with modified M3 + doping layers\n", + "sim.set_stack(stack)\n", + "sim.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0)\n", + "sim.set_geometry(comp)\n", + "\n", + "sim.set_cross_section(\"x=0\")\n", + "sim.set_boundary_mode(freq=50e9, num_modes=2, save=2)\n", + "\n", + "# -- Mesh ---------------------------------------------------------------------\n", + "# refined_mesh_size must be smaller than the rib (0.4 um wide, 0.22 um tall)\n", + "# to resolve the waveguide geometry. Use 0.05 um near conductors.\n", + "sim.mesh(\n", + " preset=\"default\",\n", + " refined_mesh_size=0.05, # resolve the 400 nm rib\n", + " max_mesh_size=40.0,\n", + " fmax=150e9,\n", + " margin_x=0.0,\n", + " margin_y=50.0,\n", + ")\n", + "\n", + "# Show the 2D domain groups created by the solver\n", + "domain_groups = list(sim._last_mesh_result.groups[\"volumes\"].keys())\n", + "print(\"2D domain groups:\", domain_groups)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# Interactive 3D mesh visualisation\n", + "# (Groups depend on what the mesh generator creates -- port surfaces\n", + "# like P1_E0 are absent in BoundaryMode 2D native mode)\n", + "sim.plot_mesh(\n", + " transparent_groups=[\"air__None\", \"air__passive\", \"oxide__passive\"],\n", + " style=\"solid\",\n", + " interactive=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# Generate Palace config file (mesh must be present)\n", + "sim.write_config()\n", + "print(\"Config written to:\", sim.output_dir)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### RF mode analysis (50 GHz)\n", + "\n", + "The BoundaryMode solver computes propagation constants and mode profiles at the RF frequency.\n", + "We demonstrate the setup below -- uncomment the `run_local` call when ready to execute." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "# -- RF simulation (50 GHz) ------------------------------------------------\n", + "# Uncomment to run. The resolver tries, in order:\n", + "# 1. PALACE_BIN env var\n", + "# 2. PALACE_EXECUTABLE env var / \"palace\" in PATH\n", + "# 3. palace-toolkit-cpu (optional pip package)\n", + "\n", + "import subprocess\n", + "import sys\n", + "\n", + "from gsim.palace.runtime import (\n", + " _palace_cpu_available,\n", + " resolve_palace_binary,\n", + ")\n", + "\n", + "bin_path = resolve_palace_binary()\n", + "if bin_path is None:\n", + " print(\n", + " \"No Palace binary found. Install palace-toolkit-cpu: pip install gsim[palace-toolkit-cpu]\"\n", + " )\n", + " sys.exit(1)\n", + "\n", + "source = \"palace-toolkit-cpu\" if _palace_cpu_available() else \"PATH / env var\"\n", + "print(f\"Palace binary: {bin_path} (source: {source})\")\n", + "\n", + "try:\n", + " ver = subprocess.run( # noqa: S603\n", + " [str(bin_path), \"--version\"],\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=10,\n", + " check=False,\n", + " )\n", + " print(ver.stdout.strip())\n", + "except Exception:\n", + " pass\n", + "\n", + "results = sim.run_local(verbose=True)\n", + "results.print()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "postproc-rf", + "metadata": {}, + "outputs": [], + "source": [ + "# -- Post-processing: RF mode fields ------------------------------------\n", + "\n", + "import importlib\n", + "\n", + "import gsim.palace.field_viz as field_viz\n", + "import gsim.palace.results as palace_results\n", + "from gsim.palace import plot_fields_2d\n", + "\n", + "importlib.reload(field_viz)\n", + "importlib.reload(palace_results)\n", + "\n", + "if not hasattr(results, \"modes\"):\n", + " results = palace_results.load_text_results(\"./palace-sim-mzm-pn\")\n", + "\n", + "results.print()\n", + "\n", + "fig, ax, stream_inputs = plot_fields_2d(\n", + " \"./palace-sim-mzm-pn\",\n", + " field=\"E_real\",\n", + " normal=\"x\",\n", + " origin=0.0,\n", + " title=\"RF Mode |E_t| at x=0 (50 GHz)\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "22bef3e8", + "metadata": {}, + "source": [ + "### Optical mode analysis (1550 nm)\n", + "\n", + "The same cross-section can be analysed at optical frequencies ($\\lambda = 1.55$ um) to compute the optical mode confined in the rib waveguide. Here we set up a second `BoundaryModeSim` targeting the optical regime." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b586d9aa", + "metadata": {}, + "outputs": [], + "source": [ + "# Optical mode at 1550 nm (~193.4 THz)\n", + "f_opt = 193.4e12 # Hz\n", + "\n", + "sim_opt = BoundaryModeSim()\n", + "sim_opt.set_output_dir(\"./palace-sim-mzm-pn-opt\")\n", + "sim_opt.set_stack(stack)\n", + "sim_opt.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0)\n", + "sim_opt.set_geometry(comp)\n", + "\n", + "sim_opt.set_cross_section(\"x=0\")\n", + "sim_opt.set_boundary_mode(\n", + " freq=f_opt,\n", + " num_modes=4,\n", + " save=2,\n", + " target=2.5,\n", + " tolerance=1e-8,\n", + ")\n", + "\n", + "sim_opt.mesh(\n", + " preset=\"default\",\n", + " refined_mesh_size=0.02,\n", + " max_mesh_size=0.5,\n", + " margin_x=0.0,\n", + " margin_y=50.0,\n", + ")\n", + "\n", + "# -- Optical simulation (1550 nm) -------------------------------------------\n", + "# Uncomment to run:\n", + "# from gsim.palace.runtime import resolve_palace_binary, _palace_cpu_available\n", + "# import subprocess\n", + "# bin_path = resolve_palace_binary()\n", + "# src = \"palace-toolkit-cpu\" if _palace_cpu_available() else \"PATH / env var\"\n", + "# print(f\"Palace binary: {bin_path} (source: {src})\")\n", + "# print(subprocess.run([str(bin_path), \"--version\"], capture_output=True, text=True).stdout.strip())\n", + "# opt_results = sim_opt.run_local(verbose=True)\n", + "# opt_results.print()" + ] + }, + { + "cell_type": "markdown", + "id": "904f65b9", + "metadata": {}, + "source": [ + "### Summary\n", + "\n", + "The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis.\n", + "\n", + "**Cross-section elements:**\n", + "| Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) |\n", + "|---|---|---|---|---|---|\n", + "| Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 |\n", + "| Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 |\n", + "| PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 |\n", + "| PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 |\n", + "| P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 |\n", + "| P+ graded outer | PP (23,0) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 |\n", + "| N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 |\n", + "| N+ graded outer | NPP (24,0) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 |\n", + "| Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", + "| Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", + "| CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "| CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "| CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "\n", + "**Material modelling notes:**\n", + "- Doping regions are modelled as **semiconductors** (finite sigma from Drude free-carrier model), not metals. This avoids short-circuiting the PN junction.\n", + "- Conductivities are derived from $\\sigma = q\\mu N$ with typical dopant concentrations ($N \\sim 10^{19}$ cm-^3 for the junction, $\\sim 10^{20}$ cm-^3 for the contacts).\n", + "- Doping on each side of the rib uses a **configurable piecewise gradient**; the helper `make_doped_materials_dict()` generates the `MaterialProperties` dict from a list of `(name, permittivity, conductivity, source)` tuples.\n", + "- The **depletion region** and voltage-dependent capacitance are NOT modelled here -- this is a linear small-signal analysis at a fixed bias point.\n", + "- The **plasma-dispersion effect** (free-carrier $\\Delta n$, $\\Delta k$) is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm. A full electro-optic analysis would require a separate carrier-dependent optical material model (Soref–Bennett).\n", + "\n", + "**Next steps (user action):**\n", + "1. Verify the zoomed cross-section plot above shows the rib (centred at y=-20), PN junction, graded doping regions, and vias.\n", + "2. Run `sim.run_local(num_processes=4, verbose=True)` with palace-toolkit-cpu installed (`pip install gsim[palace-toolkit-cpu]`).\n", + "3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode.\n", + "4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e228b2f1", + "metadata": {}, + "outputs": [], + "source": [ + "# Quick verification: dump the 2D cross-section layer regions\n", + "# The rib waveguide is now centred at y=-20 (between left G and centre S)\n", + "# Piecewise doping gradient: pp_slab_0 (inner) -> pp_slab_1 (outer) on P+ side,\n", + "# npp_slab_0 (inner) -> npp_slab_1 (outer) on N+ side\n", + "# Vias at x=0 visible as via_contact/via1/via2 regions connecting S/G to slab\n", + "print(\"=== RF cross-section regions (x=0) ===\")\n", + "for r in section:\n", + " print(\n", + " f\" {r.layer_name:12s} mat={r.material:10s} \"\n", + " f\"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]\"\n", + " )" + ] + } + ], + "metadata": { + "jupytext": { + "formats": "ipynb,py:percent" + }, + "kernelspec": { + "display_name": "gsim (3.12.3)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/_palace_2d_twmzm.py b/nbs/_palace_2d_twmzm.py new file mode 100644 index 00000000..8d37505e --- /dev/null +++ b/nbs/_palace_2d_twmzm.py @@ -0,0 +1,564 @@ +# --- +# jupyter: +# jupytext: +# formats: ipynb,py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.2 +# kernelspec: +# display_name: gsim (3.12.3) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator +# +# This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation. +# +# **Cross-section geometry (from literature):** +# - **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX) +# - **Rib waveguide**: 400 nm width, 90 nm slab height +# - **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um +# - **PN junction**: Centred in the rib with P+/N+ contact regions in the slab +# +# We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes. +# +# **Requirements:** +# - gdsfactory + generic PDK (`gf.gpdk`) +# - [GDSFactory+](https://gdsfactory.com) account (for cloud Palace runs) + +# %% [markdown] +# ### Build TW-MZM cross-section geometry +# +# We define the 3D layout component that will be sliced at $x=0$ for 2D mode analysis. +# +# **Layer assignments (gpdk):** +# - `WG` (1,0): Rib waveguide core (220 nm Si, 400 nm wide) +# - `SLAB90` (3,0): 90 nm slab regions +# - `N` (20,0) / `P` (21,0): PN junction doping +# - `NPP` (24,0) / `PP` (25,0): P+/N+ contact doping +# - `M3` (49,0): CPW electrodes (Al, 1 um thick) + +# %% +import gdsfactory as gf + +gf.gpdk.PDK.activate() + +RIB_WIDTH = 0.4 +SLAB_HALF = 20.0 +SIG_WIDTH = 20.0 +GAP_WIDTH = 20.0 +GND_WIDTH = 40.0 +TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH +LENGTH = 10.0 + +LAYER = gf.gpdk.LAYER + + +def centered_rect(wx: float, wy: float, layer) -> gf.Component: + r = gf.Component() + r << gf.c.rectangle((wx, wy), centered=True, layer=layer) + return r + + +# ============================================================================= +# Unified helper: contiguous doping profile + materials + layer specs +# ============================================================================= +def add_doping_profile( + comp: gf.Component, + length: float, + rib_center_y: float, + rib_width: float, + profile: dict[str, list[tuple[float, float]]], + permittivity: float = 11.9, + slab_zmax: float = 0.09, +) -> dict: + """ + Create contiguous doping regions on both sides of the rib and generate + the corresponding MaterialProperties and Layer definitions. + + profile = { + "upper": [(width_um, conductivity_S_per_m), ...], # P side (toward S) + "lower": [(width_um, conductivity_S_per_m), ...], # N side (toward G) + } + + Regions are placed contiguously (no gaps) starting from the rib edge. + + Returns: { + "layer_specs": {name: Layer, ...}, + "materials": {name: MaterialProperties, ...}, + "centres": {side: [y_centre, ...]}, + } + """ + from gsim.common.stack.extractor import Layer + from gsim.common.stack.materials import ( + DispersionModel, + MaterialProperties, + ValidityRange, + ) + + side_config = { + "upper": {"base_layer": (23, 0), "prefix": "pp_slab_", "sign": 1}, + "lower": {"base_layer": (24, 0), "prefix": "npp_slab_", "sign": -1}, + } + + result = {"layer_specs": {}, "materials": {}, "centres": {}} + + for side in ("upper", "lower"): + cfg = side_config[side] + regions = profile.get(side, []) + sign = cfg["sign"] + pos = rib_center_y + sign * rib_width / 2 # start at rib edge + centres = [] + + for i, (width, sigma) in enumerate(regions): + name = f"{cfg['prefix']}{i}" + gds_layer = (cfg["base_layer"][0], cfg["base_layer"][1] + i) + centre = pos + sign * width / 2 + + rect = comp << gf.c.rectangle((length, width), layer=gds_layer) + rect.y = centre + centres.append(centre) + pos += sign * width + + result["layer_specs"][name] = Layer( + name=name, + gds_layer=gds_layer, + zmin=0.0, + zmax=slab_zmax, + thickness=slab_zmax, + material=name, + layer_type="dielectric", + mesh_resolution="fine", + ) + + result["materials"][name] = MaterialProperties( + permittivity=permittivity, + conductivity=sigma, + dispersion_models=[ + DispersionModel( + type="constant", + permittivity=permittivity, + validity=ValidityRange(valid_frequency=(0, 200e9)), + source=f"doped Si ({name}) -- Drude sigma", + ), + ], + ) + + result["centres"][side] = centres + + return result + + +# ============================================================================= +# Component: TW-MZM cross-section +# ============================================================================= +comp = gf.Component() + +# Rib centre: centred in the gap between left G (y approx -50) and centre S (y=0) +RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # -20.0 + +# 1. Rib waveguide core +wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG) +wg.y = RIB_CENTER_Y + +# 2. Slab (90 nm) +slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90) +slab.y = RIB_CENTER_Y + +# 3. PN junction +p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P) +p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4 +n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N) +n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4 + +# 4+5. Doping gradient (contiguous, no gaps) +doping_profile = { + "upper": [ # P side (toward signal) + (2.0, 2.0e4), # width=2um, sigma=2e4 S/m + (2.0, 8.0e4), # width=2um, sigma=8e4 S/m + ], + "lower": [ # N side (toward ground) + (2.0, 2.0e4), + (2.0, 8.0e4), + ], +} +doping_result = add_doping_profile( + comp, LENGTH, RIB_CENTER_Y, RIB_WIDTH, doping_profile +) + +# 6. CPW electrodes (M1) +sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1) +gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) +gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2 +gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) +gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2) + +# 7. Vias at x=0 (one per electrode, overlapping electrode) +via_s_to_p = comp << gf.c.via_stack( + layers=("SLAB90", "M1"), vias=("viac", None), size=(2.7, 2.7) +) +via_s_to_p.x = 0.0 +via_s_to_p.y = -9.0 # overlaps S electrode at y=-10 +via_g_to_n = comp << gf.c.via_stack( + layers=("SLAB90", "M1"), vias=("viac", None), size=(2.7, 2.7) +) +via_g_to_n.x = 0.0 +via_g_to_n.y = -29.0 # overlaps G electrode at y=-30 + +# -- Plot ---------------------------------------------------------------------- +_cc = comp.copy() +_cc.draw_ports() +_cc.plot() + +# %% [markdown] +# ### Inspect 2D cross-section +# +# Before meshing, we extract the geometric cross-section at $x=0$ to verify that all layers are correctly defined. + +# %% +import gsim.common.cross_section as cross_section +from gsim.common.stack import get_stack +from gsim.common.stack.extractor import Layer +from gsim.common.stack.materials import ( + DispersionModel, + MaterialProperties, + ValidityRange, +) +from gsim.palace import BoundaryModeSim + +# -- Stack -------------------------------------------------------------------- +stack = get_stack( + substrate_thickness=2.0, + include_substrate=False, +) + +# Override metal1 thickness to 1 um (electrodes on M1) +m1 = stack.layers.get("metal1") +if m1: + m1.zmax = 1.1 + 1.0 + m1.thickness = 1.0 + print(f"M1 updated: zmin={m1.zmin}, zmax={m1.zmax}, thickness={m1.thickness}") + +# -- Register doping materials and layers from the profile result ----- +SI_RIB_ZMIN, SI_RIB_ZMAX = 0.0, 0.22 +SI_SLAB_ZMAX = 0.09 + +# doping_result comes from the geometry cell's add_doping_profile() +doped_materials = doping_result["materials"] +doping_layers = doping_result["layer_specs"] + +# Add the PN junction rib layers (not part of the gradient profile) +# PN junction (~1e19 cm^-3) -> sigma ~ 1600 S/m +for mat_name, gds_layer, zmax, sigma in [ + ("p_rib", LAYER.P, SI_RIB_ZMAX, 1.6e3), + ("n_rib", LAYER.N, SI_RIB_ZMAX, 1.6e3), +]: + doping_layers[mat_name] = Layer( + name=mat_name, + gds_layer=gds_layer, + zmin=SI_RIB_ZMIN, + zmax=zmax, + thickness=zmax - SI_RIB_ZMIN, + material=mat_name, + layer_type="dielectric", + mesh_resolution="fine", + ) + doped_materials[mat_name] = MaterialProperties( + permittivity=11.9, + conductivity=sigma, + dispersion_models=[ + DispersionModel( + type="constant", + permittivity=11.9, + validity=ValidityRange(valid_frequency=(0, 200e9)), + source=f"doped Si ({mat_name}) -- Drude sigma", + ), + ], + ) +for name, layer in doping_layers.items(): + stack.layers[name] = layer + +# -- Via layer definitions ---------------------------------------------------- +# The gpdk already defines via_contact, via1, via2 in the stack. We +# ensure they are present and have the right z-extents for our cross-section. +# (They are already set correctly by the gpdk extractor, listed here for +# reference.) +print(f"Stack: {stack.pdk_name}") +print("Layers:", sorted(stack.layers.keys())) +print("Custom materials:", sorted(doped_materials.keys())) +print("Dielectrics:", stack.dielectrics) +print() + +# -- Cross-section extraction ------------------------------------------------ +section = cross_section.extract_plane_section(comp.copy(), stack, axis="x", value=0.0) +print(f"Cross-section x=0 intersects {len(section)} layer regions:") +for r in section: + print( + f" {r.layer_name:12s} material={r.material:10s} " + f"y=[{r.y0:8.3f}, {r.y1:8.3f}] z=[{r.zmin:6.3f}, {r.zmax:6.3f}]" + ) + +# %% +# -- Zoomed cross-section plot (rib region) ---------------------------------- +# The full layout spans 140 um, so the 400 nm rib is invisible at full scale. +# Here we plot only the central ±15 um to show the rib, PN junction, and +# the P+/N+ ohmic contacts clearly. +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle + +fig, ax = plt.subplots(figsize=(10, 4)) + +# Colour map per layer name +colors = { + "core": "#c0392b", # rib Si -- red + "slab90": "#e67e22", # slab Si -- orange + "p_rib": "#2980b9", # P doping -- blue + "pp_slab_0": "#8e44ad", # P+ graded inner -- purple + "pp_slab_1": "#a569bd", # P+ graded outer -- light purple + "n_rib": "#27ae60", # N doping -- green + "npp_slab_0": "#4460ad", # N+ graded inner -- indigo + "npp_slab_1": "#5b7dcf", # N+ graded outer -- light indigo + "metal3": "#7f8c8d", # Al -- grey + "via_contact": "#f1c40f", # via -- gold +} + +for r in section: + c = colors.get(r.layer_name, "#dddddd") + # RectYZ2D: y0/y1 are lateral, zmin/zmax are vertical + rect = Rectangle( + (r.y0, r.zmin), + r.y1 - r.y0, + r.zmax - r.zmin, + facecolor=c, + edgecolor="k", + linewidth=0.5, + alpha=0.8, + label=r.layer_name, + ) + ax.add_patch(rect) + +ax.set_xlim(-15, 15) +ax.set_ylim(-0.5, 5.0) +ax.set_aspect("equal") +ax.set_xlabel("y (um)") +ax.set_ylabel("z (um)") +ax.set_title("TW-MZM cross-section (zoomed on rib + doping + signal electrode)") + +# Deduplicate legend +handles, labels = ax.get_legend_handles_labels() +by_label = dict(zip(labels, handles)) +ax.legend(by_label.values(), by_label.keys(), loc="upper right", fontsize=8) + +plt.tight_layout() +plt.show() + +# %% +# -- BoundaryMode 2D simulation setup ---------------------------------------- +sim = BoundaryModeSim() +sim.set_output_dir("./palace-sim-mzm-pn") + +# Custom stack with modified M3 + doping layers +sim.set_stack(stack) +sim.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0) +sim.set_geometry(comp) + +sim.set_cross_section("x=0") +sim.set_boundary_mode(freq=50e9, num_modes=2, save=2) + +# -- Mesh --------------------------------------------------------------------- +# refined_mesh_size must be smaller than the rib (0.4 um wide, 0.22 um tall) +# to resolve the waveguide geometry. Use 0.05 um near conductors. +sim.mesh( + preset="default", + refined_mesh_size=0.05, # resolve the 400 nm rib + max_mesh_size=40.0, + fmax=150e9, + margin_x=0.0, + margin_y=50.0, +) + +# Show the 2D domain groups created by the solver +domain_groups = list(sim._last_mesh_result.groups["volumes"].keys()) +print("2D domain groups:", domain_groups) + +# %% +# Interactive 3D mesh visualisation +# (Groups depend on what the mesh generator creates -- port surfaces +# like P1_E0 are absent in BoundaryMode 2D native mode) +sim.plot_mesh( + transparent_groups=["air__None", "air__passive", "oxide__passive"], + style="solid", + interactive=True, +) + +# %% +# Generate Palace config file (mesh must be present) +sim.write_config() +print("Config written to:", sim.output_dir) + +# %% [markdown] +# ### RF mode analysis (50 GHz) +# +# The BoundaryMode solver computes propagation constants and mode profiles at the RF frequency. +# We demonstrate the setup below -- uncomment the `run_local` call when ready to execute. + +# %% +# -- RF simulation (50 GHz) ------------------------------------------------ +# Uncomment to run. The resolver tries, in order: +# 1. PALACE_BIN env var +# 2. PALACE_EXECUTABLE env var / "palace" in PATH +# 3. palace-toolkit-cpu (optional pip package) + +import subprocess +import sys + +from gsim.palace.runtime import ( + _palace_cpu_available, + resolve_palace_binary, +) + +bin_path = resolve_palace_binary() +if bin_path is None: + print( + "No Palace binary found. Install palace-toolkit-cpu: pip install gsim[palace-toolkit-cpu]" + ) + sys.exit(1) + +source = "palace-toolkit-cpu" if _palace_cpu_available() else "PATH / env var" +print(f"Palace binary: {bin_path} (source: {source})") + +try: + ver = subprocess.run( # noqa: S603 + [str(bin_path), "--version"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + print(ver.stdout.strip()) +except Exception: + pass + +results = sim.run_local(verbose=True) +results.print() + +# %% +# -- Post-processing: RF mode fields ------------------------------------ + +import importlib + +import gsim.palace.field_viz as field_viz +import gsim.palace.results as palace_results +from gsim.palace import plot_fields_2d + +importlib.reload(field_viz) +importlib.reload(palace_results) + +if not hasattr(results, "modes"): + results = palace_results.load_text_results("./palace-sim-mzm-pn") + +results.print() + +fig, ax, stream_inputs = plot_fields_2d( + "./palace-sim-mzm-pn", + field="E_real", + normal="x", + origin=0.0, + title="RF Mode |E_t| at x=0 (50 GHz)", +) + +# %% [markdown] +# ### Optical mode analysis (1550 nm) +# +# The same cross-section can be analysed at optical frequencies ($\lambda = 1.55$ um) to compute the optical mode confined in the rib waveguide. Here we set up a second `BoundaryModeSim` targeting the optical regime. + +# %% +# Optical mode at 1550 nm (~193.4 THz) +f_opt = 193.4e12 # Hz + +sim_opt = BoundaryModeSim() +sim_opt.set_output_dir("./palace-sim-mzm-pn-opt") +sim_opt.set_stack(stack) +sim_opt.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0) +sim_opt.set_geometry(comp) + +sim_opt.set_cross_section("x=0") +sim_opt.set_boundary_mode( + freq=f_opt, + num_modes=4, + save=2, + target=2.5, + tolerance=1e-8, +) + +sim_opt.mesh( + preset="default", + refined_mesh_size=0.02, + max_mesh_size=0.5, + margin_x=0.0, + margin_y=50.0, +) + +# -- Optical simulation (1550 nm) ------------------------------------------- +# Uncomment to run: +# from gsim.palace.runtime import resolve_palace_binary, _palace_cpu_available +# import subprocess +# bin_path = resolve_palace_binary() +# src = "palace-toolkit-cpu" if _palace_cpu_available() else "PATH / env var" +# print(f"Palace binary: {bin_path} (source: {src})") +# print(subprocess.run([str(bin_path), "--version"], capture_output=True, text=True).stdout.strip()) +# opt_results = sim_opt.run_local(verbose=True) +# opt_results.print() + +# %% [markdown] +# ### Summary +# +# The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis. +# +# **Cross-section elements:** +# | Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) | +# |---|---|---|---|---|---| +# | Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 | +# | Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 | +# | PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 | +# | PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 | +# | P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 | +# | P+ graded outer | PP (23,0) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 | +# | N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 | +# | N+ graded outer | NPP (24,0) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 | +# | Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | +# | Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | +# | CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# | CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# | CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# +# **Material modelling notes:** +# - Doping regions are modelled as **semiconductors** (finite sigma from Drude free-carrier model), not metals. This avoids short-circuiting the PN junction. +# - Conductivities are derived from $\sigma = q\mu N$ with typical dopant concentrations ($N \sim 10^{19}$ cm-^3 for the junction, $\sim 10^{20}$ cm-^3 for the contacts). +# - Doping on each side of the rib uses a **configurable piecewise gradient**; the helper `make_doped_materials_dict()` generates the `MaterialProperties` dict from a list of `(name, permittivity, conductivity, source)` tuples. +# - The **depletion region** and voltage-dependent capacitance are NOT modelled here -- this is a linear small-signal analysis at a fixed bias point. +# - The **plasma-dispersion effect** (free-carrier $\Delta n$, $\Delta k$) is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm. A full electro-optic analysis would require a separate carrier-dependent optical material model (Soref–Bennett). +# +# **Next steps (user action):** +# 1. Verify the zoomed cross-section plot above shows the rib (centred at y=-20), PN junction, graded doping regions, and vias. +# 2. Run `sim.run_local(num_processes=4, verbose=True)` with palace-toolkit-cpu installed (`pip install gsim[palace-toolkit-cpu]`). +# 3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode. +# 4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles. +# + +# %% +# Quick verification: dump the 2D cross-section layer regions +# The rib waveguide is now centred at y=-20 (between left G and centre S) +# Piecewise doping gradient: pp_slab_0 (inner) -> pp_slab_1 (outer) on P+ side, +# npp_slab_0 (inner) -> npp_slab_1 (outer) on N+ side +# Vias at x=0 visible as via_contact/via1/via2 regions connecting S/G to slab +print("=== RF cross-section regions (x=0) ===") +for r in section: + print( + f" {r.layer_name:12s} mat={r.material:10s} " + f"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]" + ) diff --git a/pyproject.toml b/pyproject.toml index f6cdd311..b942f343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ # GitHub Releases. Currently only Linux x86_64 wheels are available, so # the extra is platform-marked accordingly. palace-toolkit-cpu = [ - "palacetoolkit-palace-cpu @ https://github.com/EpsilonForge/PalaceToolkit/releases/download/palace-cpu-v0.1.2/palacetoolkit_palace_cpu-0.1.0-py3-none-linux_x86_64.whl ; sys_platform == 'linux' and platform_machine == 'x86_64'", + "palacetoolkit-palace-cpu @ https://github.com/EpsilonForge/PalaceToolkit/releases/download/palace-cpu-v0.17.0/palacetoolkit_palace_cpu-0.17.0-py3-none-linux_x86_64.whl ; sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/src/gsim/palace/__init__.py b/src/gsim/palace/__init__.py index 3009ebbc..ad89a006 100644 --- a/src/gsim/palace/__init__.py +++ b/src/gsim/palace/__init__.py @@ -59,6 +59,9 @@ from gsim.palace.eigenmode import EigenmodeSim from gsim.palace.electrostatic import ElectrostaticSim +# Visualization +from gsim.palace.field_viz import plot_fields_2d + # Field-visualization (NaN-free direct mesh rendering) from gsim.palace.fields import ( BoundaryFieldData, @@ -134,8 +137,6 @@ # Runtime / binary resolution (optional palace-toolkit-cpu dependency) from gsim.palace.runtime import resolve_palace_binary, resolve_palace_library_dir - -# Visualization from gsim.viz import plot_cross_section, plot_mesh __all__ = [ diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 33bb6b2e..4e814048 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -8,6 +8,7 @@ import logging import math +import os import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -34,6 +35,28 @@ logger = logging.getLogger(__name__) +def _count_physical_cpus() -> int: + """Return the number of physical CPU cores (not logical threads).""" + try: + with open("/proc/cpuinfo") as f: + content = f.read() + # Each physical core has a unique (physical_id, core_id) pair. + cores: set[tuple[str, str]] = set() + phys_id = None + for line in content.splitlines(): + if line.startswith("physical id"): + phys_id = line.split(":")[1].strip() + elif line.startswith("core id"): + core_id = line.split(":")[1].strip() + if phys_id is not None: + cores.add((phys_id, core_id)) + if cores: + return len(cores) + except Exception: + pass + return os.cpu_count() or 1 + + class PalaceSimMixin: """Mixin providing common methods for all Palace simulation classes. @@ -1613,7 +1636,7 @@ def run_local( *, palace_sif_path: str | Path | None = None, palace_executable: str | Path | None = None, - use_apptainer: bool = True, + use_apptainer: bool = False, num_processes: int | None = None, num_threads: int | None = None, verbose: bool = True, @@ -1693,7 +1716,7 @@ def run_local( # Default to all available CPUs when caller does not specify -np. if num_processes is None: - num_processes = os.cpu_count() or 1 + num_processes = _count_physical_cpus() # Check required files exist if not config_path.exists(): diff --git a/src/gsim/palace/mesh/config_generator.py b/src/gsim/palace/mesh/config_generator.py index 28df3a04..911ffc3c 100644 --- a/src/gsim/palace/mesh/config_generator.py +++ b/src/gsim/palace/mesh/config_generator.py @@ -238,6 +238,15 @@ def _lookup_material(name: str) -> dict[str, object]: else: mat_entry["LossTan"] = 0.0 + # Finite-conductivity shaped dielectrics (e.g. doped silicon) + # need a Conductivity entry so Palace treats them as lossy + # semiconductors rather than ideal dielectrics. + sigma = mat_props.get("conductivity", 0.0) + if (isinstance(sigma, (int, float)) and sigma > 0) or isinstance( + sigma, list + ): + mat_entry["Conductivity"] = sigma + if "permeability" in mat_props: mat_entry["Permeability"] = mat_props["permeability"] diff --git a/src/gsim/palace/mesh/generator.py b/src/gsim/palace/mesh/generator.py index 11c15903..783d5892 100644 --- a/src/gsim/palace/mesh/generator.py +++ b/src/gsim/palace/mesh/generator.py @@ -384,6 +384,85 @@ def _generate_native_boundarymode_groups( if layer is not None and layer.layer_type in {"conductor", "via"}: metal_piece_tags.update(pieces) + # Priority-based deduplication for overlapping dielectric layers. + # + # The gmsh fragment assigns each piece to ALL parent layers that + # contain it. When a patterned layer (e.g. p_rib) is fully inside a + # larger patterned layer (e.g. core or slab90), both claim the same + # piece. We resolve this with a priority order: + # + # 1. Patterned dielectrics with a real GDS layer (doping, rib, slab) + # take priority over background dielectric regions (sio2, passive, + # air) that share the same z-range. + # 2. Among overlapping patterned dielectrics, the one with the + # *smaller* total area wins (the doping region carves out of the + # larger silicon region it is embedded in). + # + # This mirrors the 3D boolean pipeline's mesh_order convention where + # smaller / higher-detail volumes carve out of larger background volumes. + + def _is_background_dielectric(layer_name: str) -> bool: + """Background dielectric: no real GDS layer (e.g. sio2, passive, air).""" + layer = stack.layers.get(layer_name) + return layer is None or layer.gds_layer == (999, 0) + + def _is_patterned_dielectric(layer_name: str) -> bool: + """Patterned dielectric: has a real GDS layer and is dielectric type.""" + layer = stack.layers.get(layer_name) + return ( + layer is not None + and layer.layer_type == "dielectric" + and layer.gds_layer != (999, 0) + ) + + # Step 1: strip patterned-dielectric pieces from background regions. + patterned_piece_tags: set[int] = set() + for layer_name, pieces in layer_surfaces.items(): + if _is_patterned_dielectric(layer_name): + patterned_piece_tags.update(pieces) + + if patterned_piece_tags: + for layer_name, pieces in list(layer_surfaces.items()): + if _is_background_dielectric(layer_name): + filtered = pieces - patterned_piece_tags + if filtered: + layer_surfaces[layer_name] = filtered + else: + layer_surfaces.pop(layer_name, None) + + # Step 2: among patterned dielectrics, resolve overlaps by area. + # Compute the bounding-box area of each patterned layer's pieces. + patterned_names = [n for n in layer_surfaces if _is_patterned_dielectric(n)] + + def _layer_area(layer_name: str) -> float: + """Approximate area of a layer's 2D pieces via bounding boxes.""" + total = 0.0 + for stag in layer_surfaces.get(layer_name, set()): + try: + xmin, ymin, _zmin, xmax, ymax, _zmax = gmsh.model.getBoundingBox( + 2, stag + ) + total += (xmax - xmin) * (ymax - ymin) + except Exception: + pass + return total + + # Sort patterned dielectrics by area (smallest first = highest priority). + patterned_by_area = sorted(patterned_names, key=_layer_area) + + # For each patterned layer (smallest first), claim its pieces and + # remove them from all larger patterned layers. + claimed_pieces: set[int] = set() + for layer_name in patterned_by_area: + pieces = layer_surfaces.get(layer_name, set()) + # Remove pieces already claimed by smaller (higher-priority) layers. + unique = pieces - claimed_pieces + if unique: + layer_surfaces[layer_name] = unique + claimed_pieces.update(unique) + else: + layer_surfaces.pop(layer_name, None) + if metal_piece_tags: for layer_name, pieces in list(layer_surfaces.items()): layer = stack.layers.get(layer_name) @@ -463,7 +542,46 @@ def _is_conductive(value: float | list[float] | None) -> bool: pec_curves.add(ctag) if pec_curves: - curve_tags = sorted(pec_curves) + # Some curves may be internal to the conductor pieces (shared + # between two conductor surface fragments). These curves have no + # adjacent volume element in the mesh (conductor surfaces are not + # written as volume physical groups), so they would cause a + # segfault in MFEM's GetBdrElementFace. Filter them out by + # checking gmsh adjacencies: keep a curve only if at least one + # adjacent surface actually has a volume physical group. + # Build a set of all surface tags that have a volume PG. + vol_pg_surfaces: set[int] = set() + for pg_dim, pg_tag in gmsh.model.getPhysicalGroups(): + if pg_dim == 2: + ents = gmsh.model.getEntitiesForPhysicalGroup(2, pg_tag) + vol_pg_surfaces.update(int(e) for e in ents) + filtered_curves: set[int] = set() + n_internal = 0 + for ctag in pec_curves: + try: + adj = gmsh.model.getAdjacencies(1, int(ctag)) + except Exception: + filtered_curves.add(ctag) + continue + # adj[1] = dim-2 entities (surfaces) adjacent to this curve + adj_surfaces = set(adj[1]) if len(adj) > 1 else set() + if not adj_surfaces: + filtered_curves.add(ctag) + continue + # Keep the curve if at least one adjacent surface has a PG + if adj_surfaces & vol_pg_surfaces: + filtered_curves.add(ctag) + else: + n_internal += 1 + if n_internal > 0: + logger.info( + "Filtered %d internal conductor curves for '%s' (%d remaining)", + n_internal, + layer_name, + len(filtered_curves), + ) + + curve_tags = sorted(filtered_curves) sigma = _material_conductivity(layer_name) if _is_conductive(sigma): cond_pg = gmsh.model.addPhysicalGroup(1, curve_tags) diff --git a/src/gsim/palace/runtime.py b/src/gsim/palace/runtime.py index ddcf1ca3..c3f51ef4 100644 --- a/src/gsim/palace/runtime.py +++ b/src/gsim/palace/runtime.py @@ -19,6 +19,7 @@ import logging import os import shutil +import subprocess from pathlib import Path logger = logging.getLogger(__name__) @@ -116,18 +117,22 @@ def resolve_palace_library_dir() -> Path | None: def _binary_is_runnable(binary: Path, timeout: float = 15.0) -> bool: - """Quick smoke test — run `` --version``.""" - import subprocess - + """Smoke test: file exists, is executable, and responds to --version or --help.""" bin_str = str(binary) - try: - result = subprocess.run( # noqa: S603 - [bin_str, "--version"], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - except Exception: + if not binary.is_file() or not os.access(bin_str, os.X_OK): return False - return result.returncode == 0 + + for flag in ("--version", "--help"): + try: + result = subprocess.run( # noqa: S603 + [bin_str, flag], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode == 0: + return True + except Exception: + continue + return True # fallback: just being executable is enough diff --git a/src/gsim/viz.py b/src/gsim/viz.py index eb6dd1fe..e65529b1 100644 --- a/src/gsim/viz.py +++ b/src/gsim/viz.py @@ -14,11 +14,34 @@ import meshio import numpy as np -import pyvista as pv logger = logging.getLogger(__name__) +# -- Headless-safe PyVista initialisation ------------------------------------ +def _ensure_pyvista(): + """Import PyVista with off-screen rendering (headless-safe).""" + import os + import warnings + + os.environ.pop("DISPLAY", None) + os.environ.setdefault("VTK_DEFAULT_RENDER_WINDOW_OFFSCREEN", "1") + + import pyvista as pv + + pv.OFF_SCREEN = True + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + pv.start_xvfb() + except Exception: + pass + return pv + + +pv = _ensure_pyvista() + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -232,7 +255,7 @@ def _plot_solid( else: cell_blocks_3d.append((block_cells, pv_type, tags)) - active_blocks = cell_blocks_2d if cell_blocks_2d else cell_blocks_3d + active_blocks = cell_blocks_2d or cell_blocks_3d if not active_blocks: logger.warning("No supported solid cell blocks — falling back to wireframe.") @@ -341,11 +364,12 @@ def _plot_solid( def _make_plotter(interactive: bool) -> Any: """Create a PyVista plotter with standard window settings.""" + pv = _ensure_pyvista() if interactive: plotter = pv.Plotter(window_size=[1200, 900]) else: plotter = pv.Plotter(off_screen=True, window_size=[1200, 900]) - plotter.set_background("white") # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + plotter.set_background("white") return plotter From 4f0cc063b92454375a161ee6547a1debd41ad1ef Mon Sep 17 00:00:00 2001 From: mdmaas Date: Mon, 27 Jul 2026 22:16:50 -0300 Subject: [PATCH 2/6] feat: rewrite plot_fields_2d on PyVista with zoom, physical-group filtering, and per-face colors --- nbs/_palace_2d_twmzm.ipynb | 80 ++++------- nbs/_palace_2d_twmzm.py | 62 +++------ src/gsim/palace/__init__.py | 3 +- src/gsim/palace/base.py | 18 ++- src/gsim/palace/field_viz.py | 262 +++++++++++++++++++++++------------ 5 files changed, 247 insertions(+), 178 deletions(-) diff --git a/nbs/_palace_2d_twmzm.ipynb b/nbs/_palace_2d_twmzm.ipynb index a7b70284..51083414 100644 --- a/nbs/_palace_2d_twmzm.ipynb +++ b/nbs/_palace_2d_twmzm.ipynb @@ -51,7 +51,7 @@ "gf.gpdk.PDK.activate()\n", "\n", "RIB_WIDTH = 0.4\n", - "SLAB_HALF = 20.0\n", + "SLAB_HALF = 95.0\n", "SIG_WIDTH = 20.0\n", "GAP_WIDTH = 20.0\n", "GND_WIDTH = 40.0\n", @@ -170,7 +170,7 @@ "\n", "# 2. Slab (90 nm)\n", "slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", - "slab.y = RIB_CENTER_Y\n", + "slab.y = 0.0\n", "\n", "# 3. PN junction\n", "p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", @@ -403,7 +403,7 @@ "# to resolve the waveguide geometry. Use 0.05 um near conductors.\n", "sim.mesh(\n", " preset=\"default\",\n", - " refined_mesh_size=0.05, # resolve the 400 nm rib\n", + " refined_mesh_size=0.01, # resolve the 400 nm rib\n", " max_mesh_size=40.0,\n", " fmax=150e9,\n", " margin_x=0.0,\n", @@ -423,8 +423,6 @@ "outputs": [], "source": [ "# Interactive 3D mesh visualisation\n", - "# (Groups depend on what the mesh generator creates -- port surfaces\n", - "# like P1_E0 are absent in BoundaryMode 2D native mode)\n", "sim.plot_mesh(\n", " transparent_groups=[\"air__None\", \"air__passive\", \"oxide__passive\"],\n", " style=\"solid\",\n", @@ -462,42 +460,7 @@ "metadata": {}, "outputs": [], "source": [ - "# -- RF simulation (50 GHz) ------------------------------------------------\n", - "# Uncomment to run. The resolver tries, in order:\n", - "# 1. PALACE_BIN env var\n", - "# 2. PALACE_EXECUTABLE env var / \"palace\" in PATH\n", - "# 3. palace-toolkit-cpu (optional pip package)\n", - "\n", - "import subprocess\n", - "import sys\n", - "\n", - "from gsim.palace.runtime import (\n", - " _palace_cpu_available,\n", - " resolve_palace_binary,\n", - ")\n", - "\n", - "bin_path = resolve_palace_binary()\n", - "if bin_path is None:\n", - " print(\n", - " \"No Palace binary found. Install palace-toolkit-cpu: pip install gsim[palace-toolkit-cpu]\"\n", - " )\n", - " sys.exit(1)\n", - "\n", - "source = \"palace-toolkit-cpu\" if _palace_cpu_available() else \"PATH / env var\"\n", - "print(f\"Palace binary: {bin_path} (source: {source})\")\n", - "\n", - "try:\n", - " ver = subprocess.run( # noqa: S603\n", - " [str(bin_path), \"--version\"],\n", - " capture_output=True,\n", - " text=True,\n", - " timeout=10,\n", - " check=False,\n", - " )\n", - " print(ver.stdout.strip())\n", - "except Exception:\n", - " pass\n", - "\n", + "# ── RF simulation (50 GHz) ────────────────────────────────────────────────\n", "results = sim.run_local(verbose=True)\n", "results.print()" ] @@ -509,14 +472,12 @@ "metadata": {}, "outputs": [], "source": [ - "# -- Post-processing: RF mode fields ------------------------------------\n", - "\n", - "import importlib\n", + "# --- Post-processing: RF mode fields ---\n", "\n", "import gsim.palace.field_viz as field_viz\n", "import gsim.palace.results as palace_results\n", "from gsim.palace import plot_fields_2d\n", - "\n", + "import importlib\n", "importlib.reload(field_viz)\n", "importlib.reload(palace_results)\n", "\n", @@ -525,13 +486,30 @@ "\n", "results.print()\n", "\n", - "fig, ax, stream_inputs = plot_fields_2d(\n", + "pl = plot_fields_2d(\n", " \"./palace-sim-mzm-pn\",\n", " field=\"E_real\",\n", - " normal=\"x\",\n", - " origin=0.0,\n", - " title=\"RF Mode |E_t| at x=0 (50 GHz)\",\n", - ")" + " title=\"RF Mode |E| at x=0 (50 GHz)\",\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "zoom-rf-rib", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Rib waveguide zoom: select by physical group names ---\n", + "from gsim.palace import plot_fields_2d\n", + "\n", + "pl = plot_fields_2d(\n", + " \"./palace-sim-mzm-pn\",\n", + " field=\"E_real\",\n", + " physical_groups=[\"n_rib\", \"npp_slab_0\", \"npp_slab_1\",\n", + " \"p_rib\", \"pp_slab_0\", \"pp_slab_1\"],\n", + " title=\"RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)\",\n", + ")\n" ] }, { @@ -674,4 +652,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/nbs/_palace_2d_twmzm.py b/nbs/_palace_2d_twmzm.py index 8d37505e..d291d39f 100644 --- a/nbs/_palace_2d_twmzm.py +++ b/nbs/_palace_2d_twmzm.py @@ -6,7 +6,7 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.19.2 +# jupytext_version: 1.19.4 # kernelspec: # display_name: gsim (3.12.3) # language: python @@ -374,7 +374,7 @@ def add_doping_profile( # to resolve the waveguide geometry. Use 0.05 um near conductors. sim.mesh( preset="default", - refined_mesh_size=0.05, # resolve the 400 nm rib + refined_mesh_size=0.01, # resolve the 400 nm rib max_mesh_size=40.0, fmax=150e9, margin_x=0.0, @@ -387,8 +387,6 @@ def add_doping_profile( # %% # Interactive 3D mesh visualisation -# (Groups depend on what the mesh generator creates -- port surfaces -# like P1_E0 are absent in BoundaryMode 2D native mode) sim.plot_mesh( transparent_groups=["air__None", "air__passive", "oxide__passive"], style="solid", @@ -408,41 +406,6 @@ def add_doping_profile( # %% # -- RF simulation (50 GHz) ------------------------------------------------ -# Uncomment to run. The resolver tries, in order: -# 1. PALACE_BIN env var -# 2. PALACE_EXECUTABLE env var / "palace" in PATH -# 3. palace-toolkit-cpu (optional pip package) - -import subprocess -import sys - -from gsim.palace.runtime import ( - _palace_cpu_available, - resolve_palace_binary, -) - -bin_path = resolve_palace_binary() -if bin_path is None: - print( - "No Palace binary found. Install palace-toolkit-cpu: pip install gsim[palace-toolkit-cpu]" - ) - sys.exit(1) - -source = "palace-toolkit-cpu" if _palace_cpu_available() else "PATH / env var" -print(f"Palace binary: {bin_path} (source: {source})") - -try: - ver = subprocess.run( # noqa: S603 - [str(bin_path), "--version"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - print(ver.stdout.strip()) -except Exception: - pass - results = sim.run_local(verbose=True) results.print() @@ -471,6 +434,27 @@ def add_doping_profile( title="RF Mode |E_t| at x=0 (50 GHz)", ) +# %% +# -- Rib waveguide zoom: select by physical group names ---------------- +from gsim.palace import plot_fields_2d + +fig, ax, stream_inputs = plot_fields_2d( + "./palace-sim-mzm-pn", + field="E_real", + normal="x", + origin=0.0, + physical_groups=[ + "n_rib", + "npp_slab_0", + "npp_slab_1", + "p_rib", + "pp_slab_0", + "pp_slab_1", + "slab90", + ], + title="RF Mode |E_t| in the Rib Waveguide (50 GHz, zoomed)", +) + # %% [markdown] # ### Optical mode analysis (1550 nm) # diff --git a/src/gsim/palace/__init__.py b/src/gsim/palace/__init__.py index ad89a006..41e9606d 100644 --- a/src/gsim/palace/__init__.py +++ b/src/gsim/palace/__init__.py @@ -60,7 +60,7 @@ from gsim.palace.electrostatic import ElectrostaticSim # Visualization -from gsim.palace.field_viz import plot_fields_2d +from gsim.palace.field_viz import plot_fields_2d, resolve_physical_groups # Field-visualization (NaN-free direct mesh rendering) from gsim.palace.fields import ( @@ -214,6 +214,7 @@ "print_stack_table", "resolve_boundary_type_attributes", "resolve_entity_attributes", + "resolve_physical_groups", "resolve_palace_binary", "resolve_palace_library_dir", "resolve_palace_materials_at_frequency", diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 4e814048..b13126c2 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -1847,10 +1847,24 @@ def run_local( resolved_exe = bundled lib_dir = resolve_palace_library_dir() if verbose: + from gsim.palace.runtime import _palace_cpu_available as _cpu_avail + + source = "palace-toolkit-cpu" if _cpu_avail() else "PALACE_BIN / PATH" logger.info( - "Using Palace binary from runtime resolver: %s", - bundled, + "Palace binary: %s (source: %s)", + bundled, source, ) + try: + import subprocess as _sp + + ver = _sp.run( + [str(bundled), "--version"], + capture_output=True, text=True, timeout=10, check=False, + ) + if ver.returncode == 0: + logger.info(ver.stdout.strip()) + except Exception: + pass else: # Last resort: "palace" in PATH resolved_exe = "palace" diff --git a/src/gsim/palace/field_viz.py b/src/gsim/palace/field_viz.py index e69c319e..78ed6de6 100644 --- a/src/gsim/palace/field_viz.py +++ b/src/gsim/palace/field_viz.py @@ -5,7 +5,7 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, Literal, Sequence, cast import numpy as np import pyvista as pv @@ -15,6 +15,56 @@ logger = logging.getLogger(__name__) +def resolve_physical_groups( + source: str | Path, + group_names: Sequence[str], +) -> list[int]: + """Resolve physical group names to Palace attribute values. + + Reads the mesh file's physical group definitions and returns the + numeric attribute values corresponding to the requested group names. + + Args: + source: Path to the simulation output directory (containing + ``palace.msh``) or directly to the ``.msh`` file. + group_names: Physical group names to resolve (e.g. + ``["n_rib", "p_rib", "slab90"]``). + + Returns: + List of attribute values (integer tags) to use for cell filtering + in Palace Paraview output. + """ + import meshio + + path = Path(source) + if path.suffix != ".msh": + path = path / "palace.msh" + if not path.exists(): + msg = f"Mesh file not found: {path}" + raise FileNotFoundError(msg) + + m = meshio.read(str(path)) + if not hasattr(m, "field_data") or not m.field_data: + msg = f"No physical groups found in mesh: {path}" + raise ValueError(msg) + + requested = set(group_names) + found: list[int] = [] + missing = set(requested) + for name, (tag, dim) in m.field_data.items(): + if name in requested: + found.append(int(tag)) + missing.discard(name) + if missing: + available = sorted(m.field_data.keys()) + msg = ( + f"Physical group(s) not found: {sorted(missing)}. " + f"Available: {available}" + ) + raise ValueError(msg) + return found + + Axis = Literal["x", "y", "z"] @@ -236,99 +286,141 @@ def plot_fields_2d( excitation: int = 1, cycle: int | None = None, boundary: bool = False, - grid_resolution: tuple[int, int] = (360, 240), - streamplot_density: float = 1.0, - streamplot_linewidth: float = 0.9, - streamplot_color: str = "lightskyblue", - streamplot_show_arrows: bool = True, - streamplot_normalize: bool = False, - streamplot_seed_from_field: bool = True, - streamplot_seed_frac: float = 0.2, - streamplot_seed_stride: int = 2, - streamplot_mask_weak: bool = False, - streamplot_min_frac: float = 0.08, - use_targeted_gap_seeds: bool = True, - targeted_seed_offset: float = 8.0, - streamplot_minlength: float = 0.1, - streamplot_maxlength: float = 2.8, + physical_groups: Sequence[str] | None = None, cmap: str = "hot", - title: str = "In-plane |E_t|", - figsize: tuple[float, float] = (10.0, 5.0), - dpi: float = 140.0, + clim: tuple[float, float] | None = None, + title: str = "|E|", + show_edges: bool = False, + opacity: float = 1.0, show: bool = True, -) -> tuple[Any, Any, StreamplotInputs2D]: - """Plot 2D in-plane field magnitude and streamlines using Matplotlib. - - This utility centralizes the notebook plotting workflow and returns - ``(fig, ax, stream_inputs)`` for custom post-processing. + screenshot: str | Path | None = None, +) -> Any: + """Plot 2D field using PyVista, rendering directly on the mesh. + + When *physical_groups* is given, only cells belonging to those mesh + physical groups are kept before plotting. The group names are resolved + to attribute values via :func:`resolve_physical_groups`. + + The color scale is clamped to the 98th percentile of the data by + default to prevent outlier values (e.g. surface currents on + conductors) from washing out the visualization. Pass *clim* to + override. + + Args: + source: Simulation output directory, path to ``.pvtu`` / ``.vtu``, + or a pre-loaded ``pv.DataSet``. + field: Field name to plot (e.g. ``"E_real"``, ``"E_imag"``). + normal: Slice normal (``"x"``, ``"y"``, ``"z"``). + origin: Slice origin along the normal axis. + physical_groups: Optional list of physical group names to + restrict the plot to (e.g. ``["n_rib", "p_rib", "slab90"]``). + cmap: Colour-map name passed to PyVista. + clim: Optional ``(vmin, vmax)`` for the color scale. If None, + ``vmax`` is set to the 98th percentile of the data. + title: Plot title. + show_edges: Toggle mesh edges on/off. + opacity: Opacity of the mesh (0-1). + show: If True, show the interactive PyVista window. + screenshot: If given, save a screenshot to this path. + + Returns: + The ``pv.Plotter`` instance. """ - import matplotlib.pyplot as plt + from gsim.viz import _ensure_pyvista - stream_inputs = extract_streamplot_inputs_2d( - source, - field=field, - normal=normal, - origin=origin, - excitation=excitation, - cycle=cycle, - boundary=boundary, - streamplot_density=streamplot_density, - streamplot_normalize=streamplot_normalize, - streamplot_seed_from_field=streamplot_seed_from_field, - streamplot_seed_frac=streamplot_seed_frac, - streamplot_seed_stride=streamplot_seed_stride, - streamplot_mask_weak=streamplot_mask_weak, - streamplot_min_frac=streamplot_min_frac, - grid_resolution=grid_resolution, + _ensure_pyvista() + import pyvista as pv + + dataset = _source_to_dataset( + source, excitation=excitation, cycle=cycle, boundary=boundary, ) - x_grid, y_grid = np.meshgrid(stream_inputs.x, stream_inputs.y) - fig, ax = plt.subplots(figsize=figsize, dpi=dpi) - et = np.asarray(stream_inputs.et_mag, dtype=float) - im = ax.pcolormesh(x_grid, y_grid, et, cmap=cmap, shading="gouraud") - - start_points = stream_inputs.start_points - if use_targeted_gap_seeds and start_points.shape[0] >= 2: - start_points = np.vstack( - [ - start_points + np.array([0.0, targeted_seed_offset]), - start_points + np.array([0.0, -targeted_seed_offset]), - ] + if physical_groups is not None: + if not isinstance(source, (str, Path)): + msg = "physical_groups requires a file path (not a DataSet or dict)" + raise TypeError(msg) + source_path = Path(source) + output_dir = source_path if source_path.is_dir() else source_path.parent + attribute_values = resolve_physical_groups(output_dir, physical_groups) + + if "attribute" not in dataset.cell_data: + msg = "Dataset has no cell_data['attribute'] — cannot filter by physical group" + raise ValueError(msg) + attrs = np.asarray(dataset.cell_data["attribute"]) + keep = np.where(np.isin(attrs, np.asarray(attribute_values)))[0] + if keep.size == 0: + msg = f"No cells found for physical_groups={physical_groups} (attrs={attribute_values})" + raise ValueError(msg) + logger.info( + "Selected %d / %d cells for physical_groups=%s", + len(keep), dataset.n_cells, physical_groups, ) - start_points = _filter_start_points_in_bounds( - start_points, - x=stream_inputs.x, - y=stream_inputs.y, + dataset = dataset.extract_cells(keep) + + # Slice to the cross-section plane. + sliced, used_normal, axis_idx, axes = _slice_plane( + dataset, normal=normal, origin=origin, ) - stream_kwargs: dict[str, Any] = { - "x": stream_inputs.x, - "y": stream_inputs.y, - "u": stream_inputs.u, - "v": stream_inputs.v, - "color": streamplot_color, - "density": streamplot_density, - "linewidth": streamplot_linewidth, - "arrowsize": 1.0 if streamplot_show_arrows else 1e-6, - "arrowstyle": "-|>" if streamplot_show_arrows else "-", - "minlength": streamplot_minlength, - "maxlength": streamplot_maxlength, - "integration_direction": "both", - } - if start_points.shape[0] >= 2: - stream_kwargs["start_points"] = start_points - - ax.streamplot(**stream_kwargs) - ax.set_title(title) - ax.set_aspect("equal") - label_h, label_v = _plane_axis_labels(stream_inputs.normal) - ax.set_xlabel(label_h) - ax.set_ylabel(label_v) - fig.colorbar(im, ax=ax, label="E_t") - plt.tight_layout() - if show: - plt.show() - return fig, ax, stream_inputs + if field not in sliced.point_data: + available = list(sliced.point_data.keys()) + msg = f"Field '{field}' not found in dataset. Available: {available}" + raise ValueError(msg) + + # Determine the 2D scalar to plot (magnitude for vector fields). + raw = np.asarray(sliced.point_data[field]) + if raw.ndim == 2: + scalars = np.linalg.norm(raw, axis=1) + scalar_name = f"|{field}|" + sliced.point_data[scalar_name] = scalars + else: + scalar_name = field + + # Clamp color scale to 98th percentile (3D-style), unless user overrides. + if clim is not None: + _clim = clim + else: + s = np.asarray(sliced.point_data[scalar_name]) + finite = s[np.isfinite(s)] + if finite.size > 0: + vmax = float(np.percentile(finite, 98)) + if vmax > 0: + _clim = (0.0, vmax) + else: + _clim = None + else: + _clim = None + + # Camera: face the 2D plane directly (always, not just for zoom). + pl = pv.Plotter(window_size=[1200, 900]) + pl.add_mesh( + sliced, + scalars=scalar_name, + cmap=cmap, + clim=_clim, + show_edges=show_edges, + opacity=opacity, + scalar_bar_args={"title": scalar_name, "vertical": True}, + ) + pl.add_title(title, font_size=12) + + pts = sliced.points + h_vals = pts[:, axes[0]] + v_vals = pts[:, axes[1]] + center = ((h_vals.min() + h_vals.max()) / 2, + (v_vals.min() + v_vals.max()) / 2) + span = (h_vals.max() - h_vals.min(), + v_vals.max() - v_vals.min()) + dist = max(span) * 2.5 if max(span) > 0 else 1.0 + pl.camera.focal_point = (center[0], center[1], 0.0) + pl.camera.position = (center[0], center[1], dist) + + if show and screenshot is None: + pl.show() + if screenshot is not None: + pl.screenshot(str(screenshot)) + pl.close() + return pl def extract_streamplot_inputs_2d( From 8a0acd40006dc8cb53110b438a1e7cef1546791c Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 3 Aug 2026 17:17:26 -0300 Subject: [PATCH 3/6] feat: generic cross-section assembly, impedance boundary API, and lumped PN capacitance --- nbs/_palace_2d_twmzm.ipynb | 655 ----------------------- nbs/_palace_2d_twmzm.py | 548 ------------------- nbs/palace_2d_twmzm.ipynb | 627 ++++++++++++++++++++++ nbs/palace_2d_twmzm.py | 476 ++++++++++++++++ output/palace/palace.json | 7 + src/gsim/common/cross_section.py | 139 ++++- src/gsim/common/stack/__init__.py | 6 + src/gsim/common/stack/doping.py | 166 ++++++ src/gsim/common/stack/materials.py | 116 +++- src/gsim/palace/__init__.py | 4 +- src/gsim/palace/base.py | 114 +++- src/gsim/palace/boundarymode.py | 1 + src/gsim/palace/driven.py | 1 + src/gsim/palace/eigenmode.py | 1 + src/gsim/palace/electrostatic.py | 1 + src/gsim/palace/mesh/config_generator.py | 107 +++- src/gsim/palace/mesh/generator.py | 114 ++-- src/gsim/palace/models/__init__.py | 2 + src/gsim/palace/models/ports.py | 51 ++ src/gsim/palace/plane_section.py | 162 ++++++ tests/common/test_cross_section.py | 105 ++++ tests/common/test_doping_materials.py | 75 +++ tests/common/test_doping_profile.py | 127 +++++ tests/palace/test_plane_section_viz.py | 102 ++++ 24 files changed, 2458 insertions(+), 1249 deletions(-) delete mode 100644 nbs/_palace_2d_twmzm.ipynb delete mode 100644 nbs/_palace_2d_twmzm.py create mode 100644 nbs/palace_2d_twmzm.ipynb create mode 100644 nbs/palace_2d_twmzm.py create mode 100644 output/palace/palace.json create mode 100644 src/gsim/common/stack/doping.py create mode 100644 src/gsim/palace/plane_section.py create mode 100644 tests/common/test_doping_materials.py create mode 100644 tests/common/test_doping_profile.py create mode 100644 tests/palace/test_plane_section_viz.py diff --git a/nbs/_palace_2d_twmzm.ipynb b/nbs/_palace_2d_twmzm.ipynb deleted file mode 100644 index 51083414..00000000 --- a/nbs/_palace_2d_twmzm.ipynb +++ /dev/null @@ -1,655 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator\n", - "\n", - "This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation.\n", - "\n", - "**Cross-section geometry (from literature):**\n", - "- **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX)\n", - "- **Rib waveguide**: 400 nm width, 90 nm slab height\n", - "- **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um\n", - "- **PN junction**: Centred in the rib with P+/N+ contact regions in the slab\n", - "\n", - "We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes.\n", - "\n", - "**Requirements:**\n", - "- gdsfactory + generic PDK (`gf.gpdk`)\n", - "- [GDSFactory+](https://gdsfactory.com) account (for cloud Palace runs)" - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "### Build TW-MZM cross-section geometry\n", - "\n", - "We define the 3D layout component that will be sliced at $x=0$ for 2D mode analysis.\n", - "\n", - "**Layer assignments (gpdk):**\n", - "- `WG` (1,0): Rib waveguide core (220 nm Si, 400 nm wide)\n", - "- `SLAB90` (3,0): 90 nm slab regions\n", - "- `N` (20,0) / `P` (21,0): PN junction doping\n", - "- `NPP` (24,0) / `PP` (25,0): P+/N+ contact doping\n", - "- `M3` (49,0): CPW electrodes (Al, 1 um thick)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "import gdsfactory as gf\n", - "\n", - "gf.gpdk.PDK.activate()\n", - "\n", - "RIB_WIDTH = 0.4\n", - "SLAB_HALF = 95.0\n", - "SIG_WIDTH = 20.0\n", - "GAP_WIDTH = 20.0\n", - "GND_WIDTH = 40.0\n", - "TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH\n", - "LENGTH = 10.0\n", - "\n", - "LAYER = gf.gpdk.LAYER\n", - "\n", - "\n", - "def centered_rect(wx: float, wy: float, layer) -> gf.Component:\n", - " r = gf.Component()\n", - " r << gf.c.rectangle((wx, wy), centered=True, layer=layer)\n", - " return r\n", - "\n", - "\n", - "# =============================================================================\n", - "# Unified helper: contiguous doping profile + materials + layer specs\n", - "# =============================================================================\n", - "def add_doping_profile(\n", - " comp: gf.Component,\n", - " length: float,\n", - " rib_center_y: float,\n", - " rib_width: float,\n", - " profile: dict[str, list[tuple[float, float]]],\n", - " permittivity: float = 11.9,\n", - " slab_zmax: float = 0.09,\n", - ") -> dict:\n", - " \"\"\"\n", - " Create contiguous doping regions on both sides of the rib and generate\n", - " the corresponding MaterialProperties and Layer definitions.\n", - "\n", - " profile = {\n", - " \"upper\": [(width_um, conductivity_S_per_m), ...], # P side (toward S)\n", - " \"lower\": [(width_um, conductivity_S_per_m), ...], # N side (toward G)\n", - " }\n", - "\n", - " Regions are placed contiguously (no gaps) starting from the rib edge.\n", - "\n", - " Returns: {\n", - " \"layer_specs\": {name: Layer, ...},\n", - " \"materials\": {name: MaterialProperties, ...},\n", - " \"centres\": {side: [y_centre, ...]},\n", - " }\n", - " \"\"\"\n", - " from gsim.common.stack.extractor import Layer\n", - " from gsim.common.stack.materials import (\n", - " DispersionModel,\n", - " MaterialProperties,\n", - " ValidityRange,\n", - " )\n", - "\n", - " side_config = {\n", - " \"upper\": {\"base_layer\": (23, 0), \"prefix\": \"pp_slab_\", \"sign\": 1},\n", - " \"lower\": {\"base_layer\": (24, 0), \"prefix\": \"npp_slab_\", \"sign\": -1},\n", - " }\n", - "\n", - " result = {\"layer_specs\": {}, \"materials\": {}, \"centres\": {}}\n", - "\n", - " for side in (\"upper\", \"lower\"):\n", - " cfg = side_config[side]\n", - " regions = profile.get(side, [])\n", - " sign = cfg[\"sign\"]\n", - " pos = rib_center_y + sign * rib_width / 2 # start at rib edge\n", - " centres = []\n", - "\n", - " for i, (width, sigma) in enumerate(regions):\n", - " name = f\"{cfg['prefix']}{i}\"\n", - " gds_layer = (cfg[\"base_layer\"][0], cfg[\"base_layer\"][1] + i)\n", - " centre = pos + sign * width / 2\n", - "\n", - " rect = comp << gf.c.rectangle((length, width), layer=gds_layer)\n", - " rect.y = centre\n", - " centres.append(centre)\n", - " pos += sign * width\n", - "\n", - " result[\"layer_specs\"][name] = Layer(\n", - " name=name,\n", - " gds_layer=gds_layer,\n", - " zmin=0.0,\n", - " zmax=slab_zmax,\n", - " thickness=slab_zmax,\n", - " material=name,\n", - " layer_type=\"dielectric\",\n", - " mesh_resolution=\"fine\",\n", - " )\n", - "\n", - " result[\"materials\"][name] = MaterialProperties(\n", - " permittivity=permittivity,\n", - " conductivity=sigma,\n", - " dispersion_models=[\n", - " DispersionModel(\n", - " type=\"constant\",\n", - " permittivity=permittivity,\n", - " validity=ValidityRange(valid_frequency=(0, 200e9)),\n", - " source=f\"doped Si ({name}) -- Drude sigma\",\n", - " ),\n", - " ],\n", - " )\n", - "\n", - " result[\"centres\"][side] = centres\n", - "\n", - " return result\n", - "\n", - "\n", - "# =============================================================================\n", - "# Component: TW-MZM cross-section\n", - "# =============================================================================\n", - "comp = gf.Component()\n", - "\n", - "# Rib centre: centred in the gap between left G (y approx -50) and centre S (y=0)\n", - "RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # -20.0\n", - "\n", - "# 1. Rib waveguide core\n", - "wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", - "wg.y = RIB_CENTER_Y\n", - "\n", - "# 2. Slab (90 nm)\n", - "slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", - "slab.y = 0.0\n", - "\n", - "# 3. PN junction\n", - "p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", - "p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4\n", - "n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N)\n", - "n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4\n", - "\n", - "# 4+5. Doping gradient (contiguous, no gaps)\n", - "doping_profile = {\n", - " \"upper\": [ # P side (toward signal)\n", - " (2.0, 2.0e4), # width=2um, sigma=2e4 S/m\n", - " (2.0, 8.0e4), # width=2um, sigma=8e4 S/m\n", - " ],\n", - " \"lower\": [ # N side (toward ground)\n", - " (2.0, 2.0e4),\n", - " (2.0, 8.0e4),\n", - " ],\n", - "}\n", - "doping_result = add_doping_profile(\n", - " comp, LENGTH, RIB_CENTER_Y, RIB_WIDTH, doping_profile\n", - ")\n", - "\n", - "# 6. CPW electrodes (M1)\n", - "sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1)\n", - "gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", - "gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2\n", - "gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", - "gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2)\n", - "\n", - "# 7. Vias at x=0 (one per electrode, overlapping electrode)\n", - "via_s_to_p = comp << gf.c.via_stack(\n", - " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(2.7, 2.7)\n", - ")\n", - "via_s_to_p.x = 0.0\n", - "via_s_to_p.y = -9.0 # overlaps S electrode at y=-10\n", - "via_g_to_n = comp << gf.c.via_stack(\n", - " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(2.7, 2.7)\n", - ")\n", - "via_g_to_n.x = 0.0\n", - "via_g_to_n.y = -29.0 # overlaps G electrode at y=-30\n", - "\n", - "# -- Plot ----------------------------------------------------------------------\n", - "_cc = comp.copy()\n", - "_cc.draw_ports()\n", - "_cc.plot()" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "### Inspect 2D cross-section\n", - "\n", - "Before meshing, we extract the geometric cross-section at $x=0$ to verify that all layers are correctly defined." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "import gsim.common.cross_section as cross_section\n", - "from gsim.common.stack import get_stack\n", - "from gsim.common.stack.extractor import Layer\n", - "from gsim.common.stack.materials import (\n", - " DispersionModel,\n", - " MaterialProperties,\n", - " ValidityRange,\n", - ")\n", - "from gsim.palace import BoundaryModeSim\n", - "\n", - "# -- Stack --------------------------------------------------------------------\n", - "stack = get_stack(\n", - " substrate_thickness=2.0,\n", - " include_substrate=False,\n", - ")\n", - "\n", - "# Override metal1 thickness to 1 um (electrodes on M1)\n", - "m1 = stack.layers.get(\"metal1\")\n", - "if m1:\n", - " m1.zmax = 1.1 + 1.0\n", - " m1.thickness = 1.0\n", - " print(f\"M1 updated: zmin={m1.zmin}, zmax={m1.zmax}, thickness={m1.thickness}\")\n", - "\n", - "# -- Register doping materials and layers from the profile result -----\n", - "SI_RIB_ZMIN, SI_RIB_ZMAX = 0.0, 0.22\n", - "SI_SLAB_ZMAX = 0.09\n", - "\n", - "# doping_result comes from the geometry cell's add_doping_profile()\n", - "doped_materials = doping_result[\"materials\"]\n", - "doping_layers = doping_result[\"layer_specs\"]\n", - "\n", - "# Add the PN junction rib layers (not part of the gradient profile)\n", - "# PN junction (~1e19 cm^-3) -> sigma ~ 1600 S/m\n", - "for mat_name, gds_layer, zmax, sigma in [\n", - " (\"p_rib\", LAYER.P, SI_RIB_ZMAX, 1.6e3),\n", - " (\"n_rib\", LAYER.N, SI_RIB_ZMAX, 1.6e3),\n", - "]:\n", - " doping_layers[mat_name] = Layer(\n", - " name=mat_name,\n", - " gds_layer=gds_layer,\n", - " zmin=SI_RIB_ZMIN,\n", - " zmax=zmax,\n", - " thickness=zmax - SI_RIB_ZMIN,\n", - " material=mat_name,\n", - " layer_type=\"dielectric\",\n", - " mesh_resolution=\"fine\",\n", - " )\n", - " doped_materials[mat_name] = MaterialProperties(\n", - " permittivity=11.9,\n", - " conductivity=sigma,\n", - " dispersion_models=[\n", - " DispersionModel(\n", - " type=\"constant\",\n", - " permittivity=11.9,\n", - " validity=ValidityRange(valid_frequency=(0, 200e9)),\n", - " source=f\"doped Si ({mat_name}) -- Drude sigma\",\n", - " ),\n", - " ],\n", - " )\n", - "for name, layer in doping_layers.items():\n", - " stack.layers[name] = layer\n", - "\n", - "# -- Via layer definitions ----------------------------------------------------\n", - "# The gpdk already defines via_contact, via1, via2 in the stack. We\n", - "# ensure they are present and have the right z-extents for our cross-section.\n", - "# (They are already set correctly by the gpdk extractor, listed here for\n", - "# reference.)\n", - "print(f\"Stack: {stack.pdk_name}\")\n", - "print(\"Layers:\", sorted(stack.layers.keys()))\n", - "print(\"Custom materials:\", sorted(doped_materials.keys()))\n", - "print(\"Dielectrics:\", stack.dielectrics)\n", - "print()\n", - "\n", - "# -- Cross-section extraction ------------------------------------------------\n", - "section = cross_section.extract_plane_section(comp.copy(), stack, axis=\"x\", value=0.0)\n", - "print(f\"Cross-section x=0 intersects {len(section)} layer regions:\")\n", - "for r in section:\n", - " print(\n", - " f\" {r.layer_name:12s} material={r.material:10s} \"\n", - " f\"y=[{r.y0:8.3f}, {r.y1:8.3f}] z=[{r.zmin:6.3f}, {r.zmax:6.3f}]\"\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fedde670", - "metadata": {}, - "outputs": [], - "source": [ - "# -- Zoomed cross-section plot (rib region) ----------------------------------\n", - "# The full layout spans 140 um, so the 400 nm rib is invisible at full scale.\n", - "# Here we plot only the central ±15 um to show the rib, PN junction, and\n", - "# the P+/N+ ohmic contacts clearly.\n", - "import matplotlib.pyplot as plt\n", - "from matplotlib.patches import Rectangle\n", - "\n", - "fig, ax = plt.subplots(figsize=(10, 4))\n", - "\n", - "# Colour map per layer name\n", - "colors = {\n", - " \"core\": \"#c0392b\", # rib Si -- red\n", - " \"slab90\": \"#e67e22\", # slab Si -- orange\n", - " \"p_rib\": \"#2980b9\", # P doping -- blue\n", - " \"pp_slab_0\": \"#8e44ad\", # P+ graded inner -- purple\n", - " \"pp_slab_1\": \"#a569bd\", # P+ graded outer -- light purple\n", - " \"n_rib\": \"#27ae60\", # N doping -- green\n", - " \"npp_slab_0\": \"#4460ad\", # N+ graded inner -- indigo\n", - " \"npp_slab_1\": \"#5b7dcf\", # N+ graded outer -- light indigo\n", - " \"metal3\": \"#7f8c8d\", # Al -- grey\n", - " \"via_contact\": \"#f1c40f\", # via -- gold\n", - "}\n", - "\n", - "for r in section:\n", - " c = colors.get(r.layer_name, \"#dddddd\")\n", - " # RectYZ2D: y0/y1 are lateral, zmin/zmax are vertical\n", - " rect = Rectangle(\n", - " (r.y0, r.zmin),\n", - " r.y1 - r.y0,\n", - " r.zmax - r.zmin,\n", - " facecolor=c,\n", - " edgecolor=\"k\",\n", - " linewidth=0.5,\n", - " alpha=0.8,\n", - " label=r.layer_name,\n", - " )\n", - " ax.add_patch(rect)\n", - "\n", - "ax.set_xlim(-15, 15)\n", - "ax.set_ylim(-0.5, 5.0)\n", - "ax.set_aspect(\"equal\")\n", - "ax.set_xlabel(\"y (um)\")\n", - "ax.set_ylabel(\"z (um)\")\n", - "ax.set_title(\"TW-MZM cross-section (zoomed on rib + doping + signal electrode)\")\n", - "\n", - "# Deduplicate legend\n", - "handles, labels = ax.get_legend_handles_labels()\n", - "by_label = dict(zip(labels, handles))\n", - "ax.legend(by_label.values(), by_label.keys(), loc=\"upper right\", fontsize=8)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "# -- BoundaryMode 2D simulation setup ----------------------------------------\n", - "sim = BoundaryModeSim()\n", - "sim.set_output_dir(\"./palace-sim-mzm-pn\")\n", - "\n", - "# Custom stack with modified M3 + doping layers\n", - "sim.set_stack(stack)\n", - "sim.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0)\n", - "sim.set_geometry(comp)\n", - "\n", - "sim.set_cross_section(\"x=0\")\n", - "sim.set_boundary_mode(freq=50e9, num_modes=2, save=2)\n", - "\n", - "# -- Mesh ---------------------------------------------------------------------\n", - "# refined_mesh_size must be smaller than the rib (0.4 um wide, 0.22 um tall)\n", - "# to resolve the waveguide geometry. Use 0.05 um near conductors.\n", - "sim.mesh(\n", - " preset=\"default\",\n", - " refined_mesh_size=0.01, # resolve the 400 nm rib\n", - " max_mesh_size=40.0,\n", - " fmax=150e9,\n", - " margin_x=0.0,\n", - " margin_y=50.0,\n", - ")\n", - "\n", - "# Show the 2D domain groups created by the solver\n", - "domain_groups = list(sim._last_mesh_result.groups[\"volumes\"].keys())\n", - "print(\"2D domain groups:\", domain_groups)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# Interactive 3D mesh visualisation\n", - "sim.plot_mesh(\n", - " transparent_groups=[\"air__None\", \"air__passive\", \"oxide__passive\"],\n", - " style=\"solid\",\n", - " interactive=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "# Generate Palace config file (mesh must be present)\n", - "sim.write_config()\n", - "print(\"Config written to:\", sim.output_dir)" - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "### RF mode analysis (50 GHz)\n", - "\n", - "The BoundaryMode solver computes propagation constants and mode profiles at the RF frequency.\n", - "We demonstrate the setup below -- uncomment the `run_local` call when ready to execute." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "# ── RF simulation (50 GHz) ────────────────────────────────────────────────\n", - "results = sim.run_local(verbose=True)\n", - "results.print()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "postproc-rf", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Post-processing: RF mode fields ---\n", - "\n", - "import gsim.palace.field_viz as field_viz\n", - "import gsim.palace.results as palace_results\n", - "from gsim.palace import plot_fields_2d\n", - "import importlib\n", - "importlib.reload(field_viz)\n", - "importlib.reload(palace_results)\n", - "\n", - "if not hasattr(results, \"modes\"):\n", - " results = palace_results.load_text_results(\"./palace-sim-mzm-pn\")\n", - "\n", - "results.print()\n", - "\n", - "pl = plot_fields_2d(\n", - " \"./palace-sim-mzm-pn\",\n", - " field=\"E_real\",\n", - " title=\"RF Mode |E| at x=0 (50 GHz)\",\n", - ")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "zoom-rf-rib", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Rib waveguide zoom: select by physical group names ---\n", - "from gsim.palace import plot_fields_2d\n", - "\n", - "pl = plot_fields_2d(\n", - " \"./palace-sim-mzm-pn\",\n", - " field=\"E_real\",\n", - " physical_groups=[\"n_rib\", \"npp_slab_0\", \"npp_slab_1\",\n", - " \"p_rib\", \"pp_slab_0\", \"pp_slab_1\"],\n", - " title=\"RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)\",\n", - ")\n" - ] - }, - { - "cell_type": "markdown", - "id": "22bef3e8", - "metadata": {}, - "source": [ - "### Optical mode analysis (1550 nm)\n", - "\n", - "The same cross-section can be analysed at optical frequencies ($\\lambda = 1.55$ um) to compute the optical mode confined in the rib waveguide. Here we set up a second `BoundaryModeSim` targeting the optical regime." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b586d9aa", - "metadata": {}, - "outputs": [], - "source": [ - "# Optical mode at 1550 nm (~193.4 THz)\n", - "f_opt = 193.4e12 # Hz\n", - "\n", - "sim_opt = BoundaryModeSim()\n", - "sim_opt.set_output_dir(\"./palace-sim-mzm-pn-opt\")\n", - "sim_opt.set_stack(stack)\n", - "sim_opt.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0)\n", - "sim_opt.set_geometry(comp)\n", - "\n", - "sim_opt.set_cross_section(\"x=0\")\n", - "sim_opt.set_boundary_mode(\n", - " freq=f_opt,\n", - " num_modes=4,\n", - " save=2,\n", - " target=2.5,\n", - " tolerance=1e-8,\n", - ")\n", - "\n", - "sim_opt.mesh(\n", - " preset=\"default\",\n", - " refined_mesh_size=0.02,\n", - " max_mesh_size=0.5,\n", - " margin_x=0.0,\n", - " margin_y=50.0,\n", - ")\n", - "\n", - "# -- Optical simulation (1550 nm) -------------------------------------------\n", - "# Uncomment to run:\n", - "# from gsim.palace.runtime import resolve_palace_binary, _palace_cpu_available\n", - "# import subprocess\n", - "# bin_path = resolve_palace_binary()\n", - "# src = \"palace-toolkit-cpu\" if _palace_cpu_available() else \"PATH / env var\"\n", - "# print(f\"Palace binary: {bin_path} (source: {src})\")\n", - "# print(subprocess.run([str(bin_path), \"--version\"], capture_output=True, text=True).stdout.strip())\n", - "# opt_results = sim_opt.run_local(verbose=True)\n", - "# opt_results.print()" - ] - }, - { - "cell_type": "markdown", - "id": "904f65b9", - "metadata": {}, - "source": [ - "### Summary\n", - "\n", - "The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis.\n", - "\n", - "**Cross-section elements:**\n", - "| Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) |\n", - "|---|---|---|---|---|---|\n", - "| Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 |\n", - "| Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 |\n", - "| PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 |\n", - "| PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 |\n", - "| P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 |\n", - "| P+ graded outer | PP (23,0) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 |\n", - "| N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 |\n", - "| N+ graded outer | NPP (24,0) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 |\n", - "| Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", - "| Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", - "| CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", - "| CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", - "| CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", - "\n", - "**Material modelling notes:**\n", - "- Doping regions are modelled as **semiconductors** (finite sigma from Drude free-carrier model), not metals. This avoids short-circuiting the PN junction.\n", - "- Conductivities are derived from $\\sigma = q\\mu N$ with typical dopant concentrations ($N \\sim 10^{19}$ cm-^3 for the junction, $\\sim 10^{20}$ cm-^3 for the contacts).\n", - "- Doping on each side of the rib uses a **configurable piecewise gradient**; the helper `make_doped_materials_dict()` generates the `MaterialProperties` dict from a list of `(name, permittivity, conductivity, source)` tuples.\n", - "- The **depletion region** and voltage-dependent capacitance are NOT modelled here -- this is a linear small-signal analysis at a fixed bias point.\n", - "- The **plasma-dispersion effect** (free-carrier $\\Delta n$, $\\Delta k$) is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm. A full electro-optic analysis would require a separate carrier-dependent optical material model (Soref–Bennett).\n", - "\n", - "**Next steps (user action):**\n", - "1. Verify the zoomed cross-section plot above shows the rib (centred at y=-20), PN junction, graded doping regions, and vias.\n", - "2. Run `sim.run_local(num_processes=4, verbose=True)` with palace-toolkit-cpu installed (`pip install gsim[palace-toolkit-cpu]`).\n", - "3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode.\n", - "4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e228b2f1", - "metadata": {}, - "outputs": [], - "source": [ - "# Quick verification: dump the 2D cross-section layer regions\n", - "# The rib waveguide is now centred at y=-20 (between left G and centre S)\n", - "# Piecewise doping gradient: pp_slab_0 (inner) -> pp_slab_1 (outer) on P+ side,\n", - "# npp_slab_0 (inner) -> npp_slab_1 (outer) on N+ side\n", - "# Vias at x=0 visible as via_contact/via1/via2 regions connecting S/G to slab\n", - "print(\"=== RF cross-section regions (x=0) ===\")\n", - "for r in section:\n", - " print(\n", - " f\" {r.layer_name:12s} mat={r.material:10s} \"\n", - " f\"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]\"\n", - " )" - ] - } - ], - "metadata": { - "jupytext": { - "formats": "ipynb,py:percent" - }, - "kernelspec": { - "display_name": "gsim (3.12.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/nbs/_palace_2d_twmzm.py b/nbs/_palace_2d_twmzm.py deleted file mode 100644 index d291d39f..00000000 --- a/nbs/_palace_2d_twmzm.py +++ /dev/null @@ -1,548 +0,0 @@ -# --- -# jupyter: -# jupytext: -# formats: ipynb,py:percent -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.19.4 -# kernelspec: -# display_name: gsim (3.12.3) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator -# -# This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation. -# -# **Cross-section geometry (from literature):** -# - **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX) -# - **Rib waveguide**: 400 nm width, 90 nm slab height -# - **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um -# - **PN junction**: Centred in the rib with P+/N+ contact regions in the slab -# -# We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes. -# -# **Requirements:** -# - gdsfactory + generic PDK (`gf.gpdk`) -# - [GDSFactory+](https://gdsfactory.com) account (for cloud Palace runs) - -# %% [markdown] -# ### Build TW-MZM cross-section geometry -# -# We define the 3D layout component that will be sliced at $x=0$ for 2D mode analysis. -# -# **Layer assignments (gpdk):** -# - `WG` (1,0): Rib waveguide core (220 nm Si, 400 nm wide) -# - `SLAB90` (3,0): 90 nm slab regions -# - `N` (20,0) / `P` (21,0): PN junction doping -# - `NPP` (24,0) / `PP` (25,0): P+/N+ contact doping -# - `M3` (49,0): CPW electrodes (Al, 1 um thick) - -# %% -import gdsfactory as gf - -gf.gpdk.PDK.activate() - -RIB_WIDTH = 0.4 -SLAB_HALF = 20.0 -SIG_WIDTH = 20.0 -GAP_WIDTH = 20.0 -GND_WIDTH = 40.0 -TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH -LENGTH = 10.0 - -LAYER = gf.gpdk.LAYER - - -def centered_rect(wx: float, wy: float, layer) -> gf.Component: - r = gf.Component() - r << gf.c.rectangle((wx, wy), centered=True, layer=layer) - return r - - -# ============================================================================= -# Unified helper: contiguous doping profile + materials + layer specs -# ============================================================================= -def add_doping_profile( - comp: gf.Component, - length: float, - rib_center_y: float, - rib_width: float, - profile: dict[str, list[tuple[float, float]]], - permittivity: float = 11.9, - slab_zmax: float = 0.09, -) -> dict: - """ - Create contiguous doping regions on both sides of the rib and generate - the corresponding MaterialProperties and Layer definitions. - - profile = { - "upper": [(width_um, conductivity_S_per_m), ...], # P side (toward S) - "lower": [(width_um, conductivity_S_per_m), ...], # N side (toward G) - } - - Regions are placed contiguously (no gaps) starting from the rib edge. - - Returns: { - "layer_specs": {name: Layer, ...}, - "materials": {name: MaterialProperties, ...}, - "centres": {side: [y_centre, ...]}, - } - """ - from gsim.common.stack.extractor import Layer - from gsim.common.stack.materials import ( - DispersionModel, - MaterialProperties, - ValidityRange, - ) - - side_config = { - "upper": {"base_layer": (23, 0), "prefix": "pp_slab_", "sign": 1}, - "lower": {"base_layer": (24, 0), "prefix": "npp_slab_", "sign": -1}, - } - - result = {"layer_specs": {}, "materials": {}, "centres": {}} - - for side in ("upper", "lower"): - cfg = side_config[side] - regions = profile.get(side, []) - sign = cfg["sign"] - pos = rib_center_y + sign * rib_width / 2 # start at rib edge - centres = [] - - for i, (width, sigma) in enumerate(regions): - name = f"{cfg['prefix']}{i}" - gds_layer = (cfg["base_layer"][0], cfg["base_layer"][1] + i) - centre = pos + sign * width / 2 - - rect = comp << gf.c.rectangle((length, width), layer=gds_layer) - rect.y = centre - centres.append(centre) - pos += sign * width - - result["layer_specs"][name] = Layer( - name=name, - gds_layer=gds_layer, - zmin=0.0, - zmax=slab_zmax, - thickness=slab_zmax, - material=name, - layer_type="dielectric", - mesh_resolution="fine", - ) - - result["materials"][name] = MaterialProperties( - permittivity=permittivity, - conductivity=sigma, - dispersion_models=[ - DispersionModel( - type="constant", - permittivity=permittivity, - validity=ValidityRange(valid_frequency=(0, 200e9)), - source=f"doped Si ({name}) -- Drude sigma", - ), - ], - ) - - result["centres"][side] = centres - - return result - - -# ============================================================================= -# Component: TW-MZM cross-section -# ============================================================================= -comp = gf.Component() - -# Rib centre: centred in the gap between left G (y approx -50) and centre S (y=0) -RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # -20.0 - -# 1. Rib waveguide core -wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG) -wg.y = RIB_CENTER_Y - -# 2. Slab (90 nm) -slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90) -slab.y = RIB_CENTER_Y - -# 3. PN junction -p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P) -p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4 -n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N) -n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4 - -# 4+5. Doping gradient (contiguous, no gaps) -doping_profile = { - "upper": [ # P side (toward signal) - (2.0, 2.0e4), # width=2um, sigma=2e4 S/m - (2.0, 8.0e4), # width=2um, sigma=8e4 S/m - ], - "lower": [ # N side (toward ground) - (2.0, 2.0e4), - (2.0, 8.0e4), - ], -} -doping_result = add_doping_profile( - comp, LENGTH, RIB_CENTER_Y, RIB_WIDTH, doping_profile -) - -# 6. CPW electrodes (M1) -sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1) -gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) -gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2 -gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) -gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2) - -# 7. Vias at x=0 (one per electrode, overlapping electrode) -via_s_to_p = comp << gf.c.via_stack( - layers=("SLAB90", "M1"), vias=("viac", None), size=(2.7, 2.7) -) -via_s_to_p.x = 0.0 -via_s_to_p.y = -9.0 # overlaps S electrode at y=-10 -via_g_to_n = comp << gf.c.via_stack( - layers=("SLAB90", "M1"), vias=("viac", None), size=(2.7, 2.7) -) -via_g_to_n.x = 0.0 -via_g_to_n.y = -29.0 # overlaps G electrode at y=-30 - -# -- Plot ---------------------------------------------------------------------- -_cc = comp.copy() -_cc.draw_ports() -_cc.plot() - -# %% [markdown] -# ### Inspect 2D cross-section -# -# Before meshing, we extract the geometric cross-section at $x=0$ to verify that all layers are correctly defined. - -# %% -import gsim.common.cross_section as cross_section -from gsim.common.stack import get_stack -from gsim.common.stack.extractor import Layer -from gsim.common.stack.materials import ( - DispersionModel, - MaterialProperties, - ValidityRange, -) -from gsim.palace import BoundaryModeSim - -# -- Stack -------------------------------------------------------------------- -stack = get_stack( - substrate_thickness=2.0, - include_substrate=False, -) - -# Override metal1 thickness to 1 um (electrodes on M1) -m1 = stack.layers.get("metal1") -if m1: - m1.zmax = 1.1 + 1.0 - m1.thickness = 1.0 - print(f"M1 updated: zmin={m1.zmin}, zmax={m1.zmax}, thickness={m1.thickness}") - -# -- Register doping materials and layers from the profile result ----- -SI_RIB_ZMIN, SI_RIB_ZMAX = 0.0, 0.22 -SI_SLAB_ZMAX = 0.09 - -# doping_result comes from the geometry cell's add_doping_profile() -doped_materials = doping_result["materials"] -doping_layers = doping_result["layer_specs"] - -# Add the PN junction rib layers (not part of the gradient profile) -# PN junction (~1e19 cm^-3) -> sigma ~ 1600 S/m -for mat_name, gds_layer, zmax, sigma in [ - ("p_rib", LAYER.P, SI_RIB_ZMAX, 1.6e3), - ("n_rib", LAYER.N, SI_RIB_ZMAX, 1.6e3), -]: - doping_layers[mat_name] = Layer( - name=mat_name, - gds_layer=gds_layer, - zmin=SI_RIB_ZMIN, - zmax=zmax, - thickness=zmax - SI_RIB_ZMIN, - material=mat_name, - layer_type="dielectric", - mesh_resolution="fine", - ) - doped_materials[mat_name] = MaterialProperties( - permittivity=11.9, - conductivity=sigma, - dispersion_models=[ - DispersionModel( - type="constant", - permittivity=11.9, - validity=ValidityRange(valid_frequency=(0, 200e9)), - source=f"doped Si ({mat_name}) -- Drude sigma", - ), - ], - ) -for name, layer in doping_layers.items(): - stack.layers[name] = layer - -# -- Via layer definitions ---------------------------------------------------- -# The gpdk already defines via_contact, via1, via2 in the stack. We -# ensure they are present and have the right z-extents for our cross-section. -# (They are already set correctly by the gpdk extractor, listed here for -# reference.) -print(f"Stack: {stack.pdk_name}") -print("Layers:", sorted(stack.layers.keys())) -print("Custom materials:", sorted(doped_materials.keys())) -print("Dielectrics:", stack.dielectrics) -print() - -# -- Cross-section extraction ------------------------------------------------ -section = cross_section.extract_plane_section(comp.copy(), stack, axis="x", value=0.0) -print(f"Cross-section x=0 intersects {len(section)} layer regions:") -for r in section: - print( - f" {r.layer_name:12s} material={r.material:10s} " - f"y=[{r.y0:8.3f}, {r.y1:8.3f}] z=[{r.zmin:6.3f}, {r.zmax:6.3f}]" - ) - -# %% -# -- Zoomed cross-section plot (rib region) ---------------------------------- -# The full layout spans 140 um, so the 400 nm rib is invisible at full scale. -# Here we plot only the central ±15 um to show the rib, PN junction, and -# the P+/N+ ohmic contacts clearly. -import matplotlib.pyplot as plt -from matplotlib.patches import Rectangle - -fig, ax = plt.subplots(figsize=(10, 4)) - -# Colour map per layer name -colors = { - "core": "#c0392b", # rib Si -- red - "slab90": "#e67e22", # slab Si -- orange - "p_rib": "#2980b9", # P doping -- blue - "pp_slab_0": "#8e44ad", # P+ graded inner -- purple - "pp_slab_1": "#a569bd", # P+ graded outer -- light purple - "n_rib": "#27ae60", # N doping -- green - "npp_slab_0": "#4460ad", # N+ graded inner -- indigo - "npp_slab_1": "#5b7dcf", # N+ graded outer -- light indigo - "metal3": "#7f8c8d", # Al -- grey - "via_contact": "#f1c40f", # via -- gold -} - -for r in section: - c = colors.get(r.layer_name, "#dddddd") - # RectYZ2D: y0/y1 are lateral, zmin/zmax are vertical - rect = Rectangle( - (r.y0, r.zmin), - r.y1 - r.y0, - r.zmax - r.zmin, - facecolor=c, - edgecolor="k", - linewidth=0.5, - alpha=0.8, - label=r.layer_name, - ) - ax.add_patch(rect) - -ax.set_xlim(-15, 15) -ax.set_ylim(-0.5, 5.0) -ax.set_aspect("equal") -ax.set_xlabel("y (um)") -ax.set_ylabel("z (um)") -ax.set_title("TW-MZM cross-section (zoomed on rib + doping + signal electrode)") - -# Deduplicate legend -handles, labels = ax.get_legend_handles_labels() -by_label = dict(zip(labels, handles)) -ax.legend(by_label.values(), by_label.keys(), loc="upper right", fontsize=8) - -plt.tight_layout() -plt.show() - -# %% -# -- BoundaryMode 2D simulation setup ---------------------------------------- -sim = BoundaryModeSim() -sim.set_output_dir("./palace-sim-mzm-pn") - -# Custom stack with modified M3 + doping layers -sim.set_stack(stack) -sim.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0) -sim.set_geometry(comp) - -sim.set_cross_section("x=0") -sim.set_boundary_mode(freq=50e9, num_modes=2, save=2) - -# -- Mesh --------------------------------------------------------------------- -# refined_mesh_size must be smaller than the rib (0.4 um wide, 0.22 um tall) -# to resolve the waveguide geometry. Use 0.05 um near conductors. -sim.mesh( - preset="default", - refined_mesh_size=0.01, # resolve the 400 nm rib - max_mesh_size=40.0, - fmax=150e9, - margin_x=0.0, - margin_y=50.0, -) - -# Show the 2D domain groups created by the solver -domain_groups = list(sim._last_mesh_result.groups["volumes"].keys()) -print("2D domain groups:", domain_groups) - -# %% -# Interactive 3D mesh visualisation -sim.plot_mesh( - transparent_groups=["air__None", "air__passive", "oxide__passive"], - style="solid", - interactive=True, -) - -# %% -# Generate Palace config file (mesh must be present) -sim.write_config() -print("Config written to:", sim.output_dir) - -# %% [markdown] -# ### RF mode analysis (50 GHz) -# -# The BoundaryMode solver computes propagation constants and mode profiles at the RF frequency. -# We demonstrate the setup below -- uncomment the `run_local` call when ready to execute. - -# %% -# -- RF simulation (50 GHz) ------------------------------------------------ -results = sim.run_local(verbose=True) -results.print() - -# %% -# -- Post-processing: RF mode fields ------------------------------------ - -import importlib - -import gsim.palace.field_viz as field_viz -import gsim.palace.results as palace_results -from gsim.palace import plot_fields_2d - -importlib.reload(field_viz) -importlib.reload(palace_results) - -if not hasattr(results, "modes"): - results = palace_results.load_text_results("./palace-sim-mzm-pn") - -results.print() - -fig, ax, stream_inputs = plot_fields_2d( - "./palace-sim-mzm-pn", - field="E_real", - normal="x", - origin=0.0, - title="RF Mode |E_t| at x=0 (50 GHz)", -) - -# %% -# -- Rib waveguide zoom: select by physical group names ---------------- -from gsim.palace import plot_fields_2d - -fig, ax, stream_inputs = plot_fields_2d( - "./palace-sim-mzm-pn", - field="E_real", - normal="x", - origin=0.0, - physical_groups=[ - "n_rib", - "npp_slab_0", - "npp_slab_1", - "p_rib", - "pp_slab_0", - "pp_slab_1", - "slab90", - ], - title="RF Mode |E_t| in the Rib Waveguide (50 GHz, zoomed)", -) - -# %% [markdown] -# ### Optical mode analysis (1550 nm) -# -# The same cross-section can be analysed at optical frequencies ($\lambda = 1.55$ um) to compute the optical mode confined in the rib waveguide. Here we set up a second `BoundaryModeSim` targeting the optical regime. - -# %% -# Optical mode at 1550 nm (~193.4 THz) -f_opt = 193.4e12 # Hz - -sim_opt = BoundaryModeSim() -sim_opt.set_output_dir("./palace-sim-mzm-pn-opt") -sim_opt.set_stack(stack) -sim_opt.set_airbox(margin_x=50.0, margin_y=50.0, z_above=100.0, z_below=100.0) -sim_opt.set_geometry(comp) - -sim_opt.set_cross_section("x=0") -sim_opt.set_boundary_mode( - freq=f_opt, - num_modes=4, - save=2, - target=2.5, - tolerance=1e-8, -) - -sim_opt.mesh( - preset="default", - refined_mesh_size=0.02, - max_mesh_size=0.5, - margin_x=0.0, - margin_y=50.0, -) - -# -- Optical simulation (1550 nm) ------------------------------------------- -# Uncomment to run: -# from gsim.palace.runtime import resolve_palace_binary, _palace_cpu_available -# import subprocess -# bin_path = resolve_palace_binary() -# src = "palace-toolkit-cpu" if _palace_cpu_available() else "PATH / env var" -# print(f"Palace binary: {bin_path} (source: {src})") -# print(subprocess.run([str(bin_path), "--version"], capture_output=True, text=True).stdout.strip()) -# opt_results = sim_opt.run_local(verbose=True) -# opt_results.print() - -# %% [markdown] -# ### Summary -# -# The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis. -# -# **Cross-section elements:** -# | Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) | -# |---|---|---|---|---|---| -# | Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 | -# | Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 | -# | PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 | -# | PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 | -# | P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 | -# | P+ graded outer | PP (23,0) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 | -# | N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 | -# | N+ graded outer | NPP (24,0) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 | -# | Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | -# | Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | -# | CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | -# | CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | -# | CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | -# -# **Material modelling notes:** -# - Doping regions are modelled as **semiconductors** (finite sigma from Drude free-carrier model), not metals. This avoids short-circuiting the PN junction. -# - Conductivities are derived from $\sigma = q\mu N$ with typical dopant concentrations ($N \sim 10^{19}$ cm-^3 for the junction, $\sim 10^{20}$ cm-^3 for the contacts). -# - Doping on each side of the rib uses a **configurable piecewise gradient**; the helper `make_doped_materials_dict()` generates the `MaterialProperties` dict from a list of `(name, permittivity, conductivity, source)` tuples. -# - The **depletion region** and voltage-dependent capacitance are NOT modelled here -- this is a linear small-signal analysis at a fixed bias point. -# - The **plasma-dispersion effect** (free-carrier $\Delta n$, $\Delta k$) is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm. A full electro-optic analysis would require a separate carrier-dependent optical material model (Soref–Bennett). -# -# **Next steps (user action):** -# 1. Verify the zoomed cross-section plot above shows the rib (centred at y=-20), PN junction, graded doping regions, and vias. -# 2. Run `sim.run_local(num_processes=4, verbose=True)` with palace-toolkit-cpu installed (`pip install gsim[palace-toolkit-cpu]`). -# 3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode. -# 4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles. -# - -# %% -# Quick verification: dump the 2D cross-section layer regions -# The rib waveguide is now centred at y=-20 (between left G and centre S) -# Piecewise doping gradient: pp_slab_0 (inner) -> pp_slab_1 (outer) on P+ side, -# npp_slab_0 (inner) -> npp_slab_1 (outer) on N+ side -# Vias at x=0 visible as via_contact/via1/via2 regions connecting S/G to slab -print("=== RF cross-section regions (x=0) ===") -for r in section: - print( - f" {r.layer_name:12s} mat={r.material:10s} " - f"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]" - ) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb new file mode 100644 index 00000000..386ec5ea --- /dev/null +++ b/nbs/palace_2d_twmzm.ipynb @@ -0,0 +1,627 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator\n", + "\n", + "This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation.\n", + "\n", + "**Cross-section geometry (from literature):**\n", + "- **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX)\n", + "- **Rib waveguide**: 400 nm width, 90 nm slab height\n", + "- **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um\n", + "- **PN junction**: Centred in the rib with P+/N+ contact regions in the slab\n", + "\n", + "We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes.\n", + "\n", + "**Requirements:**\n", + "- gdsfactory + generic PDK (`gf.gpdk`)\n", + "- A Palace binary resolved internally by `run_local()` (e.g. `palace-toolkit-cpu`, `PALACE_BIN`, or `palace` on PATH)\n", + "- [GDSFactory+](https://gdsfactory.com) account only for cloud runs\n", + "\n", + "**Workflow:** parameters are grouped next to the stage they configure — geometry & materials, then RF, then optical.\n" + ] + }, + { + "cell_type": "markdown", + "id": "geometry-params-header", + "metadata": {}, + "source": [ + "## Geometry & materials parameters\n", + "\n", + "Define the layout dimensions, the substrate/metal stack, and the doping\n", + "(geometry + material) for the rib and graded slab regions. These are consumed\n", + "by the geometry build and by\n", + "`gsim.common.cross_section.build_doped_cross_section()`.\n", + "\n", + "The generic helpers used here are PDK-agnostic — no hardcoded values live in\n", + "the notebook.\n", + "\n", + "**Layer assignments (gpdk):**\n", + "- `WG` (1,0): Waveguide core (220 nm Si, 400 nm wide)\n", + "- `SLAB90` (3,0): 90 nm slab regions\n", + "- `N` (20,0) / `P` (21,0): PN junction doping\n", + "- `NPP` (24,0) / `PP` (23,0): N+/P+ graded contact doping (via `make_doping_profile`)\n", + "- `M1` (41,0): CPW electrodes (Al, 1 um thick)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "geometry-params", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================================================================\n", + "# Geometry & materials parameters\n", + "# =============================================================================\n", + "\n", + "# --- Device / rib geometry (um) ---------------------------------------------\n", + "RIB_WIDTH = 0.4 # rib waveguide width\n", + "RIB_HEIGHT = 0.22 # rib waveguide height (z)\n", + "SLAB_THICKNESS = 0.09 # slab height (z) on each side of the rib\n", + "SLAB_HALF = 50.0 # slab half-width (y, on each side of the rib)\n", + "SIG_WIDTH = 20.0 # CPW signal electrode width\n", + "GAP_WIDTH = 20.0 # CPW signal-to-ground gap\n", + "GND_WIDTH = 40.0 # CPW ground electrode width\n", + "LENGTH = 10.0 # layout length along the propagation axis (x)\n", + "TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH # lateral half-extent\n", + "RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # rib centre y (=-20)\n", + "\n", + "VIA_SIZE = 2.7 # via_stack footprint (square)\n", + "VIA_S_TO_P_Y = -9.0 # via from signal to P+ contact (y)\n", + "VIA_G_TO_N_Y = -29.0 # via from ground to N+ contact (y)\n", + "\n", + "# --- Substrate stack (um) -----------------------------------------------------\n", + "BOX_THICKNESS = 2.0 # buried-oxide thickness (below z=0)\n", + "METAL1_ZMIN = 1.1 # metal1 bottom (top of the oxide stack)\n", + "METAL1_THICKNESS = 1.0 # CPW electrode thickness on metal1\n", + "\n", + "# --- PN junction / doping material model -------------------------------------\n", + "SI_PERMITTIVITY = 11.9\n", + "FMAX_RF_MATERIAL = 200e9 # validity range of the constant-eps doping models (Hz)\n", + "RIB_DOPING_SIGMA = 1.6e3 # p_rib / n_rib junction conductivity (S/m)\n", + "\n", + "# Graded slab doping {side: [(width_um, sigma_S_per_m), ...]}, from the rib edge.\n", + "DOPING_PROFILE = {\n", + " \"upper\": [(2.0, 2.0e4), (2.0, 8.0e4)], # P+ graded (toward signal)\n", + " \"lower\": [(2.0, 2.0e4), (2.0, 8.0e4)], # N+ graded (toward ground)\n", + "}\n", + "DOPING_SIDES = { # config passed to make_doping_profile()\n", + " \"upper\": {\"base_layer\": (23, 0), \"name_prefix\": \"pp_slab_\", \"sign\": 1},\n", + " \"lower\": {\"base_layer\": (24, 0), \"name_prefix\": \"npp_slab_\", \"sign\": -1},\n", + "}\n", + "\n", + "# --- Cross-section plane ------------------------------------------------------\n", + "CROSS_SECTION_AXIS = \"x\"\n", + "CROSS_SECTION_VALUE = 0.0" + ] + }, + { + "cell_type": "markdown", + "id": "build-geometry-header", + "metadata": {}, + "source": [ + "## Build TW-MZM cross-section geometry\n", + "\n", + "The 3D layout component is sliced at $x=0$ for 2D mode analysis. The graded\n", + "doping regions are created with `make_doping_profile()` (no hardcoded geometry).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "build-geometry", + "metadata": {}, + "outputs": [], + "source": [ + "import gdsfactory as gf\n", + "\n", + "from gsim.common.stack.doping import make_doping_profile\n", + "\n", + "gf.gpdk.PDK.activate()\n", + "\n", + "LAYER = gf.gpdk.LAYER\n", + "\n", + "\n", + "def centered_rect(wx: float, wy: float, layer) -> gf.Component:\n", + " r = gf.Component()\n", + " r << gf.c.rectangle((wx, wy), centered=True, layer=layer)\n", + " return r\n", + "\n", + "\n", + "# --- Component: TW-MZM cross-section ------------------------------------\n", + "comp = gf.Component()\n", + "\n", + "# 1. Rib waveguide core (PN junction sits inside it)\n", + "wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", + "wg.y = RIB_CENTER_Y\n", + "\n", + "# 2. Slab (90 nm)\n", + "slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", + "slab.y = 0.0\n", + "\n", + "# 3. PN junction (P above / N below the rib centre)\n", + "p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", + "p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4\n", + "n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N)\n", + "n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4\n", + "\n", + "# 4. Graded N+/P+ slab doping (contiguous, no gaps)\n", + "doping_result = make_doping_profile(\n", + " comp,\n", + " length=LENGTH,\n", + " rib_center_y=RIB_CENTER_Y,\n", + " rib_width=RIB_WIDTH,\n", + " profile=DOPING_PROFILE,\n", + " sides=DOPING_SIDES,\n", + " zmin=0.0,\n", + " zmax=SLAB_THICKNESS,\n", + " permittivity=SI_PERMITTIVITY,\n", + " fmax=FMAX_RF_MATERIAL,\n", + ")\n", + "\n", + "# 5. CPW electrodes (M1)\n", + "sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1)\n", + "gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + "gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2\n", + "gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + "gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2)\n", + "\n", + "# 6. Vias at x=0 (signal-to-P+, ground-to-N+)\n", + "via_s_to_p = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", + ")\n", + "via_s_to_p.x = 0.0\n", + "via_s_to_p.y = VIA_S_TO_P_Y\n", + "\n", + "via_g_to_n = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", + ")\n", + "via_g_to_n.x = 0.0\n", + "via_g_to_n.y = VIA_G_TO_N_Y\n", + "\n", + "# -- Plot ----------------------------------------------------------------------\n", + "_cc = comp.copy()\n", + "_cc.draw_ports()\n", + "_cc.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "section-header", + "metadata": {}, + "source": [ + "## Inspect 2D cross-section\n", + "\n", + "The reusable `gsim.common.cross_section.build_doped_cross_section()` helper\n", + "assembles the base PDK stack, overrides `metal1`, registers the gradient doping\n", + "and PN-junction rib layers, and extracts the 2D cross-section at $x=0$.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stack-section", + "metadata": {}, + "outputs": [], + "source": [ + "# Make the helper's diagnostics visible\n", + "import logging\n", + "\n", + "logging.basicConfig(level=logging.INFO, format=\"%(levelname)s %(message)s\")\n", + "\n", + "from gsim.common.cross_section import build_doped_cross_section\n", + "\n", + "stack, section = build_doped_cross_section(\n", + " comp,\n", + " axis=CROSS_SECTION_AXIS,\n", + " value=CROSS_SECTION_VALUE,\n", + " substrate_thickness=BOX_THICKNESS,\n", + " metal1=(METAL1_ZMIN, METAL1_THICKNESS),\n", + " doping=doping_result,\n", + " rib_layers=[\n", + " (\"p_rib\", LAYER.P, RIB_DOPING_SIGMA),\n", + " (\"n_rib\", LAYER.N, RIB_DOPING_SIGMA),\n", + " ],\n", + " rib_height=RIB_HEIGHT,\n", + " permittivity=SI_PERMITTIVITY,\n", + " fmax=FMAX_RF_MATERIAL,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "plot-header", + "metadata": {}, + "source": [ + "## Plot the 2D cross-section\n", + "\n", + "The physical-group regions are rendered with the reusable\n", + "`gsim.palace.plot_plane_section()`. The full layout spans ~140 um, so we zoom\n", + "on the central region to show the rib, PN junction, graded doping and vias.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "plot-params", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Cross-section plot parameters (physical-group regions) ---------------\n", + "PLOT_ZOOM = {\"h_range\": (-15.0, 15.0), \"v_range\": (-0.5, 5.0)}\n", + "PLOT_COLORS = {\n", + " \"core\": \"#c0392b\", # rib Si -- red\n", + " \"slab90\": \"#e67e22\", # slab Si -- orange\n", + " \"p_rib\": \"#2980b9\", # P doping -- blue\n", + " \"pp_slab_0\": \"#8e44ad\", # P+ graded inner -- purple\n", + " \"pp_slab_1\": \"#a569bd\", # P+ graded outer -- light purple\n", + " \"n_rib\": \"#27ae60\", # N doping -- green\n", + " \"npp_slab_0\": \"#4460ad\", # N+ graded inner -- indigo\n", + " \"npp_slab_1\": \"#5b7dcf\", # N+ graded outer -- light indigo\n", + " \"metal1\": \"#7f8c8d\", # CPW electrodes (Al) -- grey\n", + " \"metal2\": \"#7f8c8d\",\n", + " \"metal3\": \"#7f8c8d\",\n", + " \"via_contact\": \"#f1c40f\", # via -- gold\n", + " \"via1\": \"#f1c40f\",\n", + " \"via2\": \"#f1c40f\",\n", + "}\n", + "PLOT_TITLE = \"TW-MZM cross-section (zoomed on rib + doping + signal electrode)\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "zoom-plot", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from gsim.palace import plot_plane_section\n", + "\n", + "plot_plane_section(\n", + " section,\n", + " colors=PLOT_COLORS,\n", + " h_range=PLOT_ZOOM[\"h_range\"],\n", + " v_range=PLOT_ZOOM[\"v_range\"],\n", + " title=PLOT_TITLE,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "rf-params-header", + "metadata": {}, + "source": [ + "## RF simulation (50 GHz)\n", + "\n", + "Configure the 50 GHz BoundaryMode run: simulation airbox, mesh, number of\n", + "modes, output directory, and the field quantities to plot.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-params", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================================================================\n", + "# RF parameters (50 GHz BoundaryMode run)\n", + "# =============================================================================\n", + "\n", + "# --- Simulation box (airbox + mesh margins, um) -----------------------------\n", + "AIRBOX = {\"margin_x\": 50.0, \"margin_y\": 50.0, \"z_above\": 100.0, \"z_below\": 100.0}\n", + "MESH_MARGIN_X = 0.0\n", + "MESH_MARGIN_Y = 50.0\n", + "\n", + "# --- RF solver settings -----------------------------------------------------\n", + "F_RF = 50e9\n", + "NUM_RF_MODES = 2\n", + "RF_OUTPUT_DIR = \"./palace-sim-mzm-pn\"\n", + "RF_MESH = {\n", + " \"preset\": \"default\",\n", + " \"refined_mesh_size\": 0.05,\n", + " \"max_mesh_size\": 40.0,\n", + " \"fmax\": 150e9,\n", + "}\n", + "\n", + "# --- RF post-processing -----------------------------------------------------\n", + "RF_FIELD = \"E_real\"\n", + "RF_FIELD_TITLE = \"RF Mode |E| at x=0 (50 GHz)\"\n", + "RIB_PHYSICAL_GROUPS = [\n", + " \"n_rib\",\n", + " \"npp_slab_0\",\n", + " \"npp_slab_1\",\n", + " \"p_rib\",\n", + " \"pp_slab_0\",\n", + " \"pp_slab_1\",\n", + "]\n", + "\n", + "# --- PN junction lumped model -------------------------------------------------\n", + "PN_JUNCTION_CAPACITANCE = 1e-15 # F, on the p_rib / n_rib interface" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-setup", + "metadata": {}, + "outputs": [], + "source": [ + "from gsim.palace import BoundaryModeSim\n", + "\n", + "# -- Boundary 2D simulation setup (RF) ----------------------------------\n", + "sim = BoundaryModeSim()\n", + "sim.set_output_dir(RF_OUTPUT_DIR)\n", + "sim.set_stack(stack)\n", + "sim.set_airbox(**AIRBOX)\n", + "sim.set_geometry(comp)\n", + "\n", + "sim.set_cross_section(f\"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}\")\n", + "sim.set_boundary_mode(freq=F_RF, num_modes=NUM_RF_MODES, save=2)\n", + "\n", + "# -- Mesh ---------------------------------------------------------------------\n", + "sim.mesh(\n", + " preset=RF_MESH[\"preset\"],\n", + " refined_mesh_size=RF_MESH[\"refined_mesh_size\"],\n", + " max_mesh_size=RF_MESH[\"max_mesh_size\"],\n", + " fmax=RF_MESH[\"fmax\"],\n", + " margin_x=MESH_MARGIN_X,\n", + " margin_y=MESH_MARGIN_Y,\n", + ")\n", + "\n", + "# Show the 2D domain groups created by the solver\n", + "domain_groups = list(sim._last_mesh_result.groups[\"volumes\"].keys())\n", + "print(\"2D domain groups:\", domain_groups)\n", + "sim.print_mesh_stats()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-mesh-plot", + "metadata": {}, + "outputs": [], + "source": [ + "# Interactive 3D mesh visualisation\n", + "sim.plot_mesh(\n", + " transparent_groups=[\"air__None\", \"air__passive\", \"oxide__passive\"],\n", + " style=\"solid\",\n", + " interactive=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-config", + "metadata": {}, + "outputs": [], + "source": [ + "# Generate Palace config file (mesh must be present)\n", + "sim.add_impedance_boundary(\"p_rib\", \"n_rib\", capacitance=PN_JUNCTION_CAPACITANCE)\n", + "sim.write_config()\n", + "print(\"Config written to:\", sim.output_dir)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-run", + "metadata": {}, + "outputs": [], + "source": [ + "# -- RF simulation (50 GHz) ------------------------------------------------\n", + "results = sim.run_local(verbose=True)\n", + "results.print()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-post", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Post-processing: RF mode fields ---\n", + "import importlib\n", + "\n", + "import gsim.palace.field_viz as field_viz\n", + "import gsim.palace.results as palace_results\n", + "from gsim.palace import plot_fields_2d\n", + "\n", + "importlib.reload(field_viz)\n", + "importlib.reload(palace_results)\n", + "\n", + "if not hasattr(results, \"modes\"):\n", + " results = palace_results.load_text_results(RF_OUTPUT_DIR)\n", + "\n", + "results.print()\n", + "\n", + "pl = plot_fields_2d(\n", + " RF_OUTPUT_DIR,\n", + " field=RF_FIELD,\n", + " title=RF_FIELD_TITLE,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf-postproc-rib", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Rib waveguide zoom: select by physical group names ---\n", + "from gsim.palace import plot_fields_2d\n", + "\n", + "pl = plot_fields_2d(\n", + " RF_OUTPUT_DIR,\n", + " field=RF_FIELD,\n", + " physical_groups=RIB_PHYSICAL_GROUPS,\n", + " title=\"RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "opt-params-header", + "metadata": {}, + "source": [ + "## Optical simulation (1550 nm)\n", + "\n", + "The same cross-section is analysed at optical frequencies to compute the mode\n", + "confined in the rib waveguide. The airbox/mesh margins come from the RF\n", + "section (cells run in order). `run_local()` locates the Palace binary\n", + "internally — there is no manual `subprocess` / binary-search here.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "opt-params", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================================================================\n", + "# Optical parameters (1550 nm BoundaryMode run)\n", + "# =============================================================================\n", + "\n", + "F_OPT = 193.4e12 # ~1550 nm\n", + "LAMBDA_OPT = 1.55 # reference wavelength (um)\n", + "NUM_OPT_MODES = 4\n", + "TARGET_INDEX = 2.5\n", + "TOLERANCE = 1e-8\n", + "OPT_OUTPUT_DIR = \"./palace-sim-mzm-pn-opt\"\n", + "OPT_MESH = {\n", + " \"preset\": \"default\",\n", + " \"refined_mesh_size\": 0.02,\n", + " \"max_mesh_size\": 0.5,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "optical-run", + "metadata": {}, + "outputs": [], + "source": [ + "# -- Optical setup + run (1550 nm) ---------------------------------------\n", + "sim_opt = BoundaryModeSim()\n", + "sim_opt.set_output_dir(OPT_OUTPUT_DIR)\n", + "sim_opt.set_stack(stack)\n", + "sim_opt.set_airbox(**AIRBOX)\n", + "sim_opt.set_geometry(comp)\n", + "\n", + "sim_opt.set_cross_section(f\"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}\")\n", + "sim_opt.set_boundary_mode(\n", + " freq=F_OPT,\n", + " num_modes=NUM_OPT_MODES,\n", + " save=2,\n", + " target=TARGET_INDEX,\n", + " tolerance=TOLERANCE,\n", + ")\n", + "\n", + "sim_opt.mesh(\n", + " preset=OPT_MESH[\"preset\"],\n", + " refined_mesh_size=OPT_MESH[\"refined_mesh_size\"],\n", + " max_mesh_size=OPT_MESH[\"max_mesh_size\"],\n", + " margin_x=MESH_MARGIN_X,\n", + " margin_y=MESH_MARGIN_Y,\n", + ")\n", + "\n", + "opt_results = sim_opt.run_local(verbose=True)\n", + "opt_results.print()" + ] + }, + { + "cell_type": "markdown", + "id": "summary", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis.\n", + "\n", + "**Cross-section elements:**\n", + "| Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) |\n", + "|---|---|---|---|---|---|\n", + "| Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 |\n", + "| Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 |\n", + "| PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 |\n", + "| PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 |\n", + "| P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 |\n", + "| P+ graded outer | PP (23,1) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 |\n", + "| N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 |\n", + "| N+ graded outer | NPP (24,1) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 |\n", + "| Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", + "| Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 |\n", + "| CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "| CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "| CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 |\n", + "\n", + "**Material modelling notes:**\n", + "- Doping regions are modelled as **semiconductors** (finite sigma from the Drude free-carrier model), not metals. This avoids short-circuiting the PN junction.\n", + "- Conductivities are derived from $\\sigma = q\\mu N$ with typical dopant concentrations ($N \\sim 10^{19}\\ \\text{cm}^{-3}$ for the junction, $\\sim 10^{20}\\ \\text{cm}^{-3}$ for the contacts).\n", + "- Doping on each side of the rib uses a **configurable piecewise gradient** via `make_doping_profile()`, and the whole cross-section assembly is wrapped by `build_doped_cross_section()`.\n", + "- The **depletion region** and voltage-dependent capacitance are NOT modelled here — this is a linear small-signal analysis at a fixed bias point.\n", + "- The **plasma-dispersion effect** is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm.\n", + "\n", + "**Next steps (user action):**\n", + "1. Verify the zoomed cross-section plot shows the rib (centred at y=-20), PN junction, graded doping, and vias.\n", + "2. Run `sim.run_local(num_processes=4, verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`).\n", + "3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode.\n", + "4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "final-dump", + "metadata": {}, + "outputs": [], + "source": [ + "# Quick verification: dump the 2D cross-section layer regions\n", + "print(\"=== RF cross-section regions (x=0) ===\")\n", + "for r in section:\n", + " print(\n", + " f\" {r.layer_name:12s} mat={r.material:10s} \"\n", + " f\"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]\"\n", + " )" + ] + } + ], + "metadata": { + "jupytext": { + "formats": "ipynb,py:percent" + }, + "kernelspec": { + "display_name": "gsim (3.12.3)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/palace_2d_twmzm.py b/nbs/palace_2d_twmzm.py new file mode 100644 index 00000000..731c447f --- /dev/null +++ b/nbs/palace_2d_twmzm.py @@ -0,0 +1,476 @@ +# --- +# jupyter: +# jupytext: +# formats: ipynb,py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.2 +# kernelspec: +# display_name: gsim (3.12.3) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator +# +# This notebook builds a simplified cross-section of a Travelling-Wave Mach-Zehnder Modulator (TW-MZM) with a PN-junction embedded in a rib waveguide, using CPW electrodes for RF modulation. +# +# **Cross-section geometry (from literature):** +# - **SOI substrate**: 220 nm Si on 2 um buried oxide (BOX) +# - **Rib waveguide**: 400 nm width, 90 nm slab height +# - **CPW electrodes**: Aluminium, 1 um thick, signal width $w=20$ um, gap $g=20$ um +# - **PN junction**: Centred in the rib with P+/N+ contact regions in the slab +# +# We use `BoundaryModeSim` (Palace 2D eigenmode solver) to compute both RF and optical modes. +# +# **Requirements:** +# - gdsfactory + generic PDK (`gf.gpdk`) +# - A Palace binary resolved internally by `run_local()` (e.g. `palace-toolkit-cpu`, `PALACE_BIN`, or `palace` on PATH) +# - [GDSFactory+](https://gdsfactory.com) account only for cloud runs +# +# **Workflow:** parameters are grouped next to the stage they configure — geometry & materials, then RF, then optical. +# + +# %% [markdown] +# ## Geometry & materials parameters +# +# Define the layout dimensions, the substrate/metal stack, and the doping +# (geometry + material) for the rib and graded slab regions. These are consumed +# by the geometry build and by +# `gsim.common.cross_section.build_doped_cross_section()`. +# +# The generic helpers used here are PDK-agnostic — no hardcoded values live in +# the notebook. +# +# **Layer assignments (gpdk):** +# - `WG` (1,0): Waveguide core (220 nm Si, 400 nm wide) +# - `SLAB90` (3,0): 90 nm slab regions +# - `N` (20,0) / `P` (21,0): PN junction doping +# - `NPP` (24,0) / `PP` (23,0): N+/P+ graded contact doping (via `make_doping_profile`) +# - `M1` (41,0): CPW electrodes (Al, 1 um thick) +# + +# %% +# ============================================================================= +# Geometry & materials parameters +# ============================================================================= + +# --- Device / rib geometry (um) --------------------------------------------- +RIB_WIDTH = 0.4 # rib waveguide width +RIB_HEIGHT = 0.22 # rib waveguide height (z) +SLAB_THICKNESS = 0.09 # slab height (z) on each side of the rib +SLAB_HALF = 50.0 # slab half-width (y, on each side of the rib) +SIG_WIDTH = 20.0 # CPW signal electrode width +GAP_WIDTH = 20.0 # CPW signal-to-ground gap +GND_WIDTH = 40.0 # CPW ground electrode width +LENGTH = 10.0 # layout length along the propagation axis (x) +TOTAL_HALF = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH # lateral half-extent +RIB_CENTER_Y = -(SIG_WIDTH / 2 + GAP_WIDTH / 2) # rib centre y (=-20) + +VIA_SIZE = 2.7 # via_stack footprint (square) +VIA_S_TO_P_Y = -9.0 # via from signal to P+ contact (y) +VIA_G_TO_N_Y = -29.0 # via from ground to N+ contact (y) + +# --- Substrate stack (um) ----------------------------------------------------- +BOX_THICKNESS = 2.0 # buried-oxide thickness (below z=0) +METAL1_ZMIN = 1.1 # metal1 bottom (top of the oxide stack) +METAL1_THICKNESS = 1.0 # CPW electrode thickness on metal1 + +# --- PN junction / doping material model ------------------------------------- +SI_PERMITTIVITY = 11.9 +FMAX_RF_MATERIAL = 200e9 # validity range of the constant-eps doping models (Hz) +RIB_DOPING_SIGMA = 1.6e3 # p_rib / n_rib junction conductivity (S/m) + +# Graded slab doping {side: [(width_um, sigma_S_per_m), ...]}, from the rib edge. +DOPING_PROFILE = { + "upper": [(2.0, 2.0e4), (2.0, 8.0e4)], # P+ graded (toward signal) + "lower": [(2.0, 2.0e4), (2.0, 8.0e4)], # N+ graded (toward ground) +} +DOPING_SIDES = { # config passed to make_doping_profile() + "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, + "lower": {"base_layer": (24, 0), "name_prefix": "npp_slab_", "sign": -1}, +} + +# --- Cross-section plane ------------------------------------------------------ +CROSS_SECTION_AXIS = "x" +CROSS_SECTION_VALUE = 0.0 + +# %% [markdown] +# ## Build TW-MZM cross-section geometry +# +# The 3D layout component is sliced at $x=0$ for 2D mode analysis. The graded +# doping regions are created with `make_doping_profile()` (no hardcoded geometry). +# + +# %% +import gdsfactory as gf + +from gsim.common.stack.doping import make_doping_profile + +gf.gpdk.PDK.activate() + +LAYER = gf.gpdk.LAYER + + +def centered_rect(wx: float, wy: float, layer) -> gf.Component: + r = gf.Component() + r << gf.c.rectangle((wx, wy), centered=True, layer=layer) + return r + + +# --- Component: TW-MZM cross-section ------------------------------------ +comp = gf.Component() + +# 1. Rib waveguide core (PN junction sits inside it) +wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG) +wg.y = RIB_CENTER_Y + +# 2. Slab (90 nm) +slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90) +slab.y = 0.0 + +# 3. PN junction (P above / N below the rib centre) +p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P) +p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4 +n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N) +n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4 + +# 4. Graded N+/P+ slab doping (contiguous, no gaps) +doping_result = make_doping_profile( + comp, + length=LENGTH, + rib_center_y=RIB_CENTER_Y, + rib_width=RIB_WIDTH, + profile=DOPING_PROFILE, + sides=DOPING_SIDES, + zmin=0.0, + zmax=SLAB_THICKNESS, + permittivity=SI_PERMITTIVITY, + fmax=FMAX_RF_MATERIAL, +) + +# 5. CPW electrodes (M1) +sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1) +gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) +gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2 +gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1) +gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2) + +# 6. Vias at x=0 (signal-to-P+, ground-to-N+) +via_s_to_p = comp << gf.c.via_stack( + layers=("SLAB90", "M1"), vias=("viac", None), size=(VIA_SIZE, VIA_SIZE) +) +via_s_to_p.x = 0.0 +via_s_to_p.y = VIA_S_TO_P_Y + +via_g_to_n = comp << gf.c.via_stack( + layers=("SLAB90", "M1"), vias=("viac", None), size=(VIA_SIZE, VIA_SIZE) +) +via_g_to_n.x = 0.0 +via_g_to_n.y = VIA_G_TO_N_Y + +# -- Plot ---------------------------------------------------------------------- +_cc = comp.copy() +_cc.draw_ports() +_cc.plot() + +# %% [markdown] +# ## Inspect 2D cross-section +# +# The reusable `gsim.common.cross_section.build_doped_cross_section()` helper +# assembles the base PDK stack, overrides `metal1`, registers the gradient doping +# and PN-junction rib layers, and extracts the 2D cross-section at $x=0$. +# + +# %% +# Make the helper's diagnostics visible +import logging + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + +from gsim.common.cross_section import build_doped_cross_section + +stack, section = build_doped_cross_section( + comp, + axis=CROSS_SECTION_AXIS, + value=CROSS_SECTION_VALUE, + substrate_thickness=BOX_THICKNESS, + metal1=(METAL1_ZMIN, METAL1_THICKNESS), + doping=doping_result, + rib_layers=[ + ("p_rib", LAYER.P, RIB_DOPING_SIGMA), + ("n_rib", LAYER.N, RIB_DOPING_SIGMA), + ], + rib_height=RIB_HEIGHT, + permittivity=SI_PERMITTIVITY, + fmax=FMAX_RF_MATERIAL, +) + +# %% [markdown] +# ## Plot the 2D cross-section +# +# The physical-group regions are rendered with the reusable +# `gsim.palace.plot_plane_section()`. The full layout spans ~140 um, so we zoom +# on the central region to show the rib, PN junction, graded doping and vias. +# + +# %% +# --- Cross-section plot parameters (physical-group regions) --------------- +PLOT_ZOOM = {"h_range": (-15.0, 15.0), "v_range": (-0.5, 5.0)} +PLOT_COLORS = { + "core": "#c0392b", # rib Si -- red + "slab90": "#e67e22", # slab Si -- orange + "p_rib": "#2980b9", # P doping -- blue + "pp_slab_0": "#8e44ad", # P+ graded inner -- purple + "pp_slab_1": "#a569bd", # P+ graded outer -- light purple + "n_rib": "#27ae60", # N doping -- green + "npp_slab_0": "#4460ad", # N+ graded inner -- indigo + "npp_slab_1": "#5b7dcf", # N+ graded outer -- light indigo + "metal1": "#7f8c8d", # CPW electrodes (Al) -- grey + "metal2": "#7f8c8d", + "metal3": "#7f8c8d", + "via_contact": "#f1c40f", # via -- gold + "via1": "#f1c40f", + "via2": "#f1c40f", +} +PLOT_TITLE = "TW-MZM cross-section (zoomed on rib + doping + signal electrode)" + +# %% +import matplotlib.pyplot as plt + +from gsim.palace import plot_plane_section + +plot_plane_section( + section, + colors=PLOT_COLORS, + h_range=PLOT_ZOOM["h_range"], + v_range=PLOT_ZOOM["v_range"], + title=PLOT_TITLE, +) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## RF simulation (50 GHz) +# +# Configure the 50 GHz BoundaryMode run: simulation airbox, mesh, number of +# modes, output directory, and the field quantities to plot. +# + +# %% +# ============================================================================= +# RF parameters (50 GHz BoundaryMode run) +# ============================================================================= + +# --- Simulation box (airbox + mesh margins, um) ----------------------------- +AIRBOX = {"margin_x": 50.0, "margin_y": 50.0, "z_above": 100.0, "z_below": 100.0} +MESH_MARGIN_X = 0.0 +MESH_MARGIN_Y = 50.0 + +# --- RF solver settings ----------------------------------------------------- +F_RF = 50e9 +NUM_RF_MODES = 2 +RF_OUTPUT_DIR = "./palace-sim-mzm-pn" +RF_MESH = { + "preset": "default", + "refined_mesh_size": 0.05, + "max_mesh_size": 40.0, + "fmax": 150e9, +} + +# --- RF post-processing ----------------------------------------------------- +RF_FIELD = "E_real" +RF_FIELD_TITLE = "RF Mode |E| at x=0 (50 GHz)" +RIB_PHYSICAL_GROUPS = [ + "n_rib", + "npp_slab_0", + "npp_slab_1", + "p_rib", + "pp_slab_0", + "pp_slab_1", +] + +# --- PN junction lumped model ------------------------------------------------- +PN_JUNCTION_CAPACITANCE = 1e-15 # F, on the p_rib / n_rib interface + +# %% +from gsim.palace import BoundaryModeSim + +# -- Boundary 2D simulation setup (RF) ---------------------------------- +sim = BoundaryModeSim() +sim.set_output_dir(RF_OUTPUT_DIR) +sim.set_stack(stack) +sim.set_airbox(**AIRBOX) +sim.set_geometry(comp) + +sim.set_cross_section(f"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}") +sim.set_boundary_mode(freq=F_RF, num_modes=NUM_RF_MODES, save=2) + +# -- Mesh --------------------------------------------------------------------- +sim.mesh( + preset=RF_MESH["preset"], + refined_mesh_size=RF_MESH["refined_mesh_size"], + max_mesh_size=RF_MESH["max_mesh_size"], + fmax=RF_MESH["fmax"], + margin_x=MESH_MARGIN_X, + margin_y=MESH_MARGIN_Y, +) + +# Show the 2D domain groups created by the solver +domain_groups = list(sim._last_mesh_result.groups["volumes"].keys()) +print("2D domain groups:", domain_groups) +sim.print_mesh_stats() + +# %% +# Interactive 3D mesh visualisation +sim.plot_mesh( + transparent_groups=["air__None", "air__passive", "oxide__passive"], + style="solid", + interactive=True, +) + +# %% +# Generate Palace config file (mesh must be present) +sim.add_impedance_boundary("p_rib", "n_rib", capacitance=PN_JUNCTION_CAPACITANCE) +sim.write_config() +print("Config written to:", sim.output_dir) + +# %% +# -- RF simulation (50 GHz) ------------------------------------------------ +results = sim.run_local(verbose=True) +results.print() + +# %% +# --- Post-processing: RF mode fields --- +import importlib + +import gsim.palace.field_viz as field_viz +import gsim.palace.results as palace_results +from gsim.palace import plot_fields_2d + +importlib.reload(field_viz) +importlib.reload(palace_results) + +if not hasattr(results, "modes"): + results = palace_results.load_text_results(RF_OUTPUT_DIR) + +results.print() + +pl = plot_fields_2d( + RF_OUTPUT_DIR, + field=RF_FIELD, + title=RF_FIELD_TITLE, +) + +# %% +# --- Rib waveguide zoom: select by physical group names --- +from gsim.palace import plot_fields_2d + +pl = plot_fields_2d( + RF_OUTPUT_DIR, + field=RF_FIELD, + physical_groups=RIB_PHYSICAL_GROUPS, + title="RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)", +) + +# %% [markdown] +# ## Optical simulation (1550 nm) +# +# The same cross-section is analysed at optical frequencies to compute the mode +# confined in the rib waveguide. The airbox/mesh margins come from the RF +# section (cells run in order). `run_local()` locates the Palace binary +# internally — there is no manual `subprocess` / binary-search here. +# + +# %% +# ============================================================================= +# Optical parameters (1550 nm BoundaryMode run) +# ============================================================================= + +F_OPT = 193.4e12 # ~1550 nm +LAMBDA_OPT = 1.55 # reference wavelength (um) +NUM_OPT_MODES = 4 +TARGET_INDEX = 2.5 +TOLERANCE = 1e-8 +OPT_OUTPUT_DIR = "./palace-sim-mzm-pn-opt" +OPT_MESH = { + "preset": "default", + "refined_mesh_size": 0.02, + "max_mesh_size": 0.5, +} + +# %% +# -- Optical setup + run (1550 nm) --------------------------------------- +sim_opt = BoundaryModeSim() +sim_opt.set_output_dir(OPT_OUTPUT_DIR) +sim_opt.set_stack(stack) +sim_opt.set_airbox(**AIRBOX) +sim_opt.set_geometry(comp) + +sim_opt.set_cross_section(f"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}") +sim_opt.set_boundary_mode( + freq=F_OPT, + num_modes=NUM_OPT_MODES, + save=2, + target=TARGET_INDEX, + tolerance=TOLERANCE, +) + +sim_opt.mesh( + preset=OPT_MESH["preset"], + refined_mesh_size=OPT_MESH["refined_mesh_size"], + max_mesh_size=OPT_MESH["max_mesh_size"], + margin_x=MESH_MARGIN_X, + margin_y=MESH_MARGIN_Y, +) + +opt_results = sim_opt.run_local(verbose=True) +opt_results.print() + +# %% [markdown] +# ## Summary +# +# The geometry has been built and meshed for both RF (50 GHz) and optical (193 THz / 1550 nm) analysis. +# +# **Cross-section elements:** +# | Component | Layer | y-range (um) | z-range (um) | Material | sigma (S/m) | +# |---|---|---|---|---|---| +# | Rib core | WG (1,0) | [-20.2, -19.8] | [0, 0.22] | Si (intrinsic) | 2 | +# | Slab (90 nm) | SLAB90 (3,0) | [-40.2, +0.2] | [0, 0.09] | Si (intrinsic) | 2 | +# | PN junction (P) | P (21,0) | [-20.0, -19.8] | [0, 0.22] | doped Si (p_rib) | 1.6x10^3 | +# | PN junction (N) | N (20,0) | [-20.2, -20.0] | [0, 0.22] | doped Si (n_rib) | 1.6x10^3 | +# | P+ graded inner | PP (23,0) | [-18.3, -16.3] | [0, 0.09] | doped Si (pp_slab_0) | 2x10^4 | +# | P+ graded outer | PP (23,1) | [-15.3, -13.3] | [0, 0.09] | doped Si (pp_slab_1) | 8x10^4 | +# | N+ graded inner | NPP (24,0) | [-21.7, -23.7] | [0, 0.09] | doped Si (npp_slab_0) | 2x10^4 | +# | N+ graded outer | NPP (24,1) | [-24.7, -26.7] | [0, 0.09] | doped Si (npp_slab_1) | 8x10^4 | +# | Vias (S to P+) | VIAC/VIA1/VIA2 | [-15.0, -9.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | +# | Vias (G to N+) | VIAC/VIA1/VIA2 | [-31.0, -25.0] | [0.09, 3.2] | W/Al | 3.5x10^7 | +# | CPW signal | M1 (41,0) | [-10, +10] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# | CPW ground (top) | M1 (41,0) | [+30, +70] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# | CPW ground (bot) | M1 (41,0) | [-70, -30] | [3.2, 4.2] | Al (1 um) | 3.5x10^7 | +# +# **Material modelling notes:** +# - Doping regions are modelled as **semiconductors** (finite sigma from the Drude free-carrier model), not metals. This avoids short-circuiting the PN junction. +# - Conductivities are derived from $\sigma = q\mu N$ with typical dopant concentrations ($N \sim 10^{19}\ \text{cm}^{-3}$ for the junction, $\sim 10^{20}\ \text{cm}^{-3}$ for the contacts). +# - Doping on each side of the rib uses a **configurable piecewise gradient** via `make_doping_profile()`, and the whole cross-section assembly is wrapped by `build_doped_cross_section()`. +# - The **depletion region** and voltage-dependent capacitance are NOT modelled here — this is a linear small-signal analysis at a fixed bias point. +# - The **plasma-dispersion effect** is not applied to the optical simulation; the rib is treated as intrinsic Si at 1550 nm. +# +# **Next steps (user action):** +# 1. Verify the zoomed cross-section plot shows the rib (centred at y=-20), PN junction, graded doping, and vias. +# 2. Run `sim.run_local(num_processes=4, verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). +# 3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode. +# 4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups. +# + +# %% +# Quick verification: dump the 2D cross-section layer regions +print("=== RF cross-section regions (x=0) ===") +for r in section: + print( + f" {r.layer_name:12s} mat={r.material:10s} " + f"y=[{r.y0:6.2f},{r.y1:6.2f}] z=[{r.zmin:5.3f},{r.zmax:5.3f}]" + ) diff --git a/output/palace/palace.json b/output/palace/palace.json new file mode 100644 index 00000000..f8bff13c --- /dev/null +++ b/output/palace/palace.json @@ -0,0 +1,7 @@ +{ + "GitTag": "v0.16.1-51-g4f2e2d97", + "Problem": { + "MPISize": 4, + "OpenMPThreads": 1 + } +} diff --git a/src/gsim/common/cross_section.py b/src/gsim/common/cross_section.py index 133c46b3..e4f52d20 100644 --- a/src/gsim/common/cross_section.py +++ b/src/gsim/common/cross_section.py @@ -11,15 +11,16 @@ from __future__ import annotations import logging +from collections.abc import Iterable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload -logger = logging.getLogger(__name__) +from gsim.common.stack import Layer, LayerStack, get_stack, make_doped_materials if TYPE_CHECKING: import gdsfactory as gf - from gsim.common.stack import LayerStack +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -213,6 +214,137 @@ def extract_plane_section( ) +def build_doped_cross_section( + component: gf.Component, + *, + axis: Literal["x", "y", "z"], + value: float, + substrate_thickness: float, + include_substrate: bool = False, + doping: Mapping[str, Mapping[str, Any]] | None = None, + metal1: tuple[float, float] | None = None, + rib_layers: Iterable[tuple[str, tuple[int, int], float]] = (), + rib_height: float = 0.22, + permittivity: float = 11.9, + fmax: float = 200e9, + verbose: bool = True, +) -> tuple[LayerStack, list[Rect2D] | list[RectYZ2D] | list[PolygonXY2D]]: + """Assemble a doped ``LayerStack`` and extract a 2D plane section from it. + + Builds the base PDK stack, optionally overrides the ``metal1`` electrodes, + registers the gradient-doping layers/materials produced by + :func:`gsim.common.stack.doping.make_doping_profile` plus any additional rib + doping layers (e.g. a PN junction), and slices the component at the requested + plane. + + Args: + component: gdsfactory component the cross-section is extracted from. + axis: Cross-section normal axis. + value: Plane coordinate in um. + substrate_thickness: Thickness below z=0 in um. + include_substrate: Include the lossy silicon substrate. + doping: Output of ``make_doping_profile`` (keys ``layer_specs`` and + ``materials``), or ``None`` to skip gradient doping. + metal1: ``(zmin, thickness)`` override for the ``metal1`` layer, or + ``None`` to keep the PDK value. + rib_layers: Sequence of ``(name, gds_layer, sigma)`` for extra rib doping + regions (e.g. PN junction) added on top of the gradient profile. + rib_height: Z-extent (z=0..rib_height) of the rib doping regions (um). + permittivity: Relative permittivity of the doping materials. + fmax: Upper frequency of the doping dispersion-model validity (Hz). + verbose: Print the assembled stack and the extracted section. + + Returns: + ``(stack, section)`` with the populated ``LayerStack`` and the list of + ``Rect2D`` / ``RectYZ2D`` / ``PolygonXY2D`` regions at the plane. + """ + stack = get_stack( + substrate_thickness=substrate_thickness, + include_substrate=include_substrate, + ) + + if metal1 is not None: + metal1_zmin, metal1_thickness = metal1 + m1 = stack.layers.get("metal1") + if m1: + m1.zmin = metal1_zmin + m1.zmax = metal1_zmin + metal1_thickness + m1.thickness = metal1_thickness + if verbose: + logger.info( + "M1 updated: zmin=%s, zmax=%s, thickness=%s", + m1.zmin, + m1.zmax, + m1.thickness, + ) + + layer_specs: dict[str, Any] = {} + materials: dict[str, Any] = {} + if doping: + layer_specs.update(doping.get("layer_specs", {})) + materials.update(doping.get("materials", {})) + + rib_materials = make_doped_materials( + [(name, sigma) for name, _gds, sigma in rib_layers], + permittivity=permittivity, + fmax=fmax, + ) + materials.update(rib_materials) + for name, gds_layer, _sigma in rib_layers: + layer_specs[name] = Layer( + name=name, + gds_layer=gds_layer, + zmin=0.0, + zmax=rib_height, + thickness=rib_height, + material=name, + layer_type="dielectric", + mesh_resolution="fine", + ) + + for name, layer in layer_specs.items(): + stack.layers[name] = layer + + section = extract_plane_section( + component.copy(), + stack, + axis=axis, + value=value, + ) + + if verbose: + logger.info("Stack: %s", stack.pdk_name) + logger.info("Layers: %s", sorted(stack.layers.keys())) + logger.info("Cross materials: %s", sorted(materials.keys())) + logger.info("Dielectrics: %s", stack.dielectrics) + logger.info("") + logger.info( + "Cross-section %s=%s intersects %s layer regions:", + axis, + value, + len(section), + ) + for r in section: + lo = getattr(r, "y0", getattr(r, "x0", None)) + hi = getattr(r, "y1", getattr(r, "x1", None)) + zlo = getattr(r, "zmin", None) + zhi = getattr(r, "zmax", None) + if zlo is None: + logger.info(" %-12s material=%-10s", r.layer_name, r.material) + else: + logger.info( + " %-12s material=%-10s y=[%8.3f, %8.3f] z=[%6.3f, %6.3f]", + r.layer_name, + r.material, + lo, + hi, + zlo, + zhi, + ) + + return stack, section + + @overload def _extract_line_slice_rectangles( component, @@ -436,6 +568,7 @@ def _line_intervals( "PolygonXY2D", "Rect2D", "RectYZ2D", + "build_doped_cross_section", "extract_plane_section", "extract_xy_polygons", "extract_xz_rectangles", diff --git a/src/gsim/common/stack/__init__.py b/src/gsim/common/stack/__init__.py index 6bc85ec7..57bcd5c5 100644 --- a/src/gsim/common/stack/__init__.py +++ b/src/gsim/common/stack/__init__.py @@ -23,6 +23,7 @@ import gdsfactory as gf import yaml +from gsim.common.stack.doping import make_doping_profile from gsim.common.stack.extractor import ( Layer, LayerStack, @@ -39,6 +40,8 @@ SellmeierTerm, ValidityRange, get_material_properties, + make_doped_material, + make_doped_materials, resolve_material_at_wavelength, should_enable_dispersion, ) @@ -180,6 +183,9 @@ def load_stack_yaml(yaml_path: str | Path) -> LayerStack: "get_stack", "load_overlay", "load_stack_yaml", + "make_doped_material", + "make_doped_materials", + "make_doping_profile", "merge_overlay", "parse_layer_stack", "plot_stack", diff --git a/src/gsim/common/stack/doping.py b/src/gsim/common/stack/doping.py new file mode 100644 index 00000000..51de63c8 --- /dev/null +++ b/src/gsim/common/stack/doping.py @@ -0,0 +1,166 @@ +"""Doping-profile construction for semiconductor cross-sections. + +This module provides solver-agnostic helpers to build contiguous (gapless) +doping regions on both sides of a rib/waveguide and to generate the +corresponding ``Layer`` specs (``gsim.common.stack.extractor``) and +``MaterialProperties`` (``gsim.common.stack.materials``). + +All geometry-specific values (layer tuples, naming prefixes, doping widths, +conductivities, z-extents) are caller-supplied — nothing is hardcoded here so +the helpers are reusable across PDKs and processes. + +Example: +------- + >>> import gdsfactory as gf + >>> from gsim.common.stack.doping import make_doping_profile + >>> comp = gf.Component() + >>> result = make_doping_profile( + ... comp, + ... length=10.0, + ... rib_center_y=-20.0, + ... rib_width=0.4, + ... profile={ + ... "upper": [(2.0, 2e4), (2.0, 8e4)], + ... "lower": [(2.0, 2e4), (2.0, 8e4)], + ... }, + ... sides={ + ... "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, + ... "lower": { + ... "base_layer": (24, 0), + ... "name_prefix": "npp_slab_", + ... "sign": -1, + ... }, + ... }, + ... zmin=0.0, + ... zmax=0.09, + ... ) + >>> result["layer_specs"] + >>> result["materials"] + >>> result["centres"] +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import gdsfactory as gf + +from gsim.common.stack.materials import make_doped_materials + +if TYPE_CHECKING: + from gsim.common.stack.extractor import Layer + +_SideConfig = dict[str, dict[str, Any]] + + +def make_doping_profile( + comp: gf.Component, + *, + length: float, + rib_center_y: float, + rib_width: float, + profile: dict[str, list[tuple[float, float]]], + sides: _SideConfig, + zmin: float, + zmax: float, + permittivity: float = 11.9, + fmax: float = 200e9, + mesh_resolution: str | float = "fine", +) -> dict[str, dict[str, Any]]: + """Add contiguous doping regions beside a rib and build layer/material specs. + + For each side (e.g. ``"upper"`` / ``"lower"``) the regions listed in + *profile* are placed as adjacent rectangles starting at the rib edge and + extending outward, so the doping is contiguous with no gaps. Each region + ``i`` on a side gets: + + - a gdsfactory rectangle of size ``(length, width)`` on the GDS layer + ``(base_layer[0], base_layer[1] + i)``, + - a ``Layer`` spec named ``"{name_prefix}{i}"``, + - a ``MaterialProperties`` entry with the region's Drude conductivity. + + Args: + comp: gdsfactory component the rectangles are added to. + length: Rectangle length along the propagation direction (um). + rib_center_y: Y coordinate of the rib centre (um). + rib_width: Rib width (um); regions start at the rib edges. + profile: Per-side region list ``{side: [(width_um, sigma_S_per_m), ...]}``. + sides: Per-side configuration: each value is a dict with keys + ``base_layer`` (``(layer, datatype)`` tuple for the first region), + ``name_prefix`` (region-name prefix) and ``sign`` (+1 extends in + +y, -1 in -y). + zmin: Bottom z of the doping regions (um). + zmax: Top z of the doping regions (um). + permittivity: Relative permittivity shared by all regions (e.g. 11.9). + fmax: Upper frequency of the dispersion-model validity range (Hz). + mesh_resolution: Mesh resolution assigned to the generated ``Layer``. + + Returns: + Dict with keys ``layer_specs`` (``{name: Layer}``), ``materials`` + (``{name: MaterialProperties}``) and ``centres`` + (``{side: [y_centre, ...]}``). + """ + from gsim.common.stack.extractor import Layer + + result: dict[str, dict[str, Any]] = { + "layer_specs": {}, + "materials": {}, + "centres": {}, + } + layer_specs = cast("dict[str, Layer]", result["layer_specs"]) + materials: dict[str, Any] = result["materials"] + centres: dict[str, list[float]] = result["centres"] + + for side, cfg in sides.items(): + regions = profile.get(side, []) + sign = cfg["sign"] + base_layer = tuple(cfg["base_layer"]) + prefix = cfg["name_prefix"] + + pos = rib_center_y + sign * rib_width / 2 # start at rib edge + side_centres: list[float] = [] + side_specs: dict[str, tuple[Any, float]] = {} + + for i, (width, sigma) in enumerate(regions): + name = f"{prefix}{i}" + gds_layer = (base_layer[0], base_layer[1] + i) + centre = pos + sign * width / 2 + + rect = comp << gf.c.rectangle((length, width), layer=gds_layer) + rect.y = centre + side_centres.append(centre) + side_specs[name] = (gds_layer, sigma) + pos += sign * width + + centres[side] = side_centres + if not side_specs: + continue + + layer_specs.update( + { + name: Layer( + name=name, + gds_layer=gds_layer, + zmin=zmin, + zmax=zmax, + thickness=zmax - zmin, + material=name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + for name, (gds_layer, _sigma) in side_specs.items() + } + ) + materials.update( + make_doped_materials( + [(name, sigma) for name, (_gds, sigma) in side_specs.items()], + permittivity=permittivity, + fmax=fmax, + source_prefix="doped Si", + ) + ) + + return result + + +__all__ = ["make_doping_profile"] diff --git a/src/gsim/common/stack/materials.py b/src/gsim/common/stack/materials.py index bbba49bd..f9da42b4 100644 --- a/src/gsim/common/stack/materials.py +++ b/src/gsim/common/stack/materials.py @@ -24,7 +24,8 @@ import math import warnings -from typing import Literal +from collections.abc import Iterable +from typing import Literal, cast from pydantic import BaseModel, ConfigDict, Field from scipy.constants import c as C0 # noqa: N812 @@ -576,6 +577,119 @@ def dielectric( return cls(permittivity=permittivity, loss_tangent=loss_tangent) +def make_doped_material( + name: str, + conductivity: float, + *, + permittivity: float = 11.9, + fmax: float = 200e9, + source: str | None = None, +) -> MaterialProperties: + """Create ``MaterialProperties`` for a doped semiconductor. + + Doped silicon (and similar semiconductors) are modelled with a finite + Drude conductivity ``sigma`` and a constant relative permittivity that + is valid up to ``fmax`` (in Hz). This is the building block used by + :func:`make_doped_materials` and the doping-profile helpers. + + Args: + name: Material name (used to label the dispersion-model source). + conductivity: DC conductivity in S/m (e.g. from ``sigma = q*mu*N``). + permittivity: Relative permittivity (default: 11.9 for silicon). + fmax: Upper frequency of the validity range in Hz. + source: Optional citation/source string. Defaults to + ``"doped Si ({name}) -- Drude sigma"``. + + Returns: + A ``MaterialProperties`` instance with a constant dispersion model. + """ + source = source or f"doped Si ({name}) -- Drude sigma" + return MaterialProperties( + permittivity=permittivity, + conductivity=conductivity, + dispersion_models=[ + DispersionModel( + type="constant", + permittivity=permittivity, + validity=ValidityRange(valid_frequency=(0, fmax)), + source=source, + ) + ], + ) + + +def make_doped_materials( + entries: ( + dict[str, float] + | Iterable[tuple[str, float]] + | Iterable[tuple[str, float, float, str]] + ), + *, + permittivity: float = 11.9, + fmax: float = 200e9, + source_prefix: str = "doped Si", +) -> dict[str, MaterialProperties]: + """Create a dict of doped-material ``MaterialProperties``. + + Each entry describes one doping region and is either a ``(name, sigma)`` + pair (using the shared *permittivity* / *fmax*) or a 4-tuple + ``(name, permittivity, sigma, source)`` for full control. + + Args: + entries: ``{name: sigma}`` dict, ``[(name, sigma), ...]`` list, or + ``[(name, permittivity, sigma, source), ...]`` list. + permittivity: Relative permittivity for 2-tuple entries. + fmax: Upper validity frequency in Hz for 2-tuple entries. + source_prefix: Source label prefix for 2-tuple entries. + + Returns: + Dict mapping each name to a ``MaterialProperties`` instance. + + Examples: + >>> make_doped_materials({"pp_slab_0": 2e4, "pp_slab_1": 8e4}) + >>> make_doped_materials([("p_rib", 11.9, 1.6e3, "junction")]) + """ + items: list[tuple[str, float, float, str]] = [] + + if isinstance(entries, dict): + for name, sigma in cast(dict[str, float], entries).items(): + items.append( + (name, permittivity, sigma, f"{source_prefix} ({name}) -- Drude sigma") + ) + else: + for entry in entries: + if len(entry) == 2: + name, sigma = cast(tuple[str, float], entry) # type: ignore[redundant-cast] + items.append( + ( + name, + permittivity, + sigma, + f"{source_prefix} ({name}) -- Drude sigma", + ) + ) + elif len(entry) == 4: + items.append(cast(tuple[str, float, float, str], entry)) # type: ignore[redundant-cast] + else: + msg = ( + "Entries must be (name, sigma) or (name, permittivity, " + f"sigma, source) tuples, got {entry!r}" + ) + raise ValueError(msg) + + materials: dict[str, MaterialProperties] = {} + for name, eps, sigma, source in items: + materials[name] = make_doped_material( + name, + sigma, + permittivity=eps, + fmax=fmax, + source=source, + ) + + return materials + + MATERIALS_DB: dict[str, MaterialProperties] = { "aluminum": MaterialProperties( conductivity=3.77e7, diff --git a/src/gsim/palace/__init__.py b/src/gsim/palace/__init__.py index 41e9606d..e0732dd9 100644 --- a/src/gsim/palace/__init__.py +++ b/src/gsim/palace/__init__.py @@ -114,6 +114,7 @@ ValidationResult, WavePortConfig, ) +from gsim.palace.plane_section import plot_plane_section # Port utilities from gsim.palace.ports import ( @@ -206,6 +207,7 @@ "plot_cross_section", "plot_fields_2d", "plot_mesh", + "plot_plane_section", "plot_stack", "plot_volume_contours", "plot_volume_slice", @@ -214,10 +216,10 @@ "print_stack_table", "resolve_boundary_type_attributes", "resolve_entity_attributes", - "resolve_physical_groups", "resolve_palace_binary", "resolve_palace_library_dir", "resolve_palace_materials_at_frequency", + "resolve_physical_groups", "resolve_scalar_field", "run_simulation", ] diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index b13126c2..f243cdcd 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -17,6 +17,7 @@ CPWPortConfig, DrivenConfig, EigenmodeConfig, + ImpedanceBoundaryConfig, MaterialConfig, MeshConfig, NumericalConfig, @@ -85,6 +86,7 @@ class PalaceSimMixin: _stack_kwargs: dict[str, Any] _pec_blocks: list _hints: dict[str, Any] + _impedance_boundaries: list[ImpedanceBoundaryConfig] absorbing_boundary: bool _airbox_config: dict[str, float] @@ -284,6 +286,45 @@ def _apply_airbox_overrides( z_below=z_below if z_below is not None else current.get("z_below"), ) + # ------------------------------------------------------------------------- + # Impedance boundaries + # ------------------------------------------------------------------------- + + def add_impedance_boundary( + self, + layer_a: str, + layer_b: str, + *, + capacitance: float | None = None, + resistance: float | None = None, + inductance: float | None = None, + name: str | None = None, + ) -> None: + """Add an Impedance boundary on the interface between two dielectric layers. + + The capacitance, resistance, and inductance values are absolute and + are divided by the interface curve length internally to produce the + per-unit-length ``Cs``, ``Rs``, ``Ls`` values expected by Palace. + + Args: + layer_a: First dielectric layer name. + layer_b: Second dielectric layer name. + capacitance: Absolute capacitance [F]. + resistance: Absolute resistance [Ohm]. + inductance: Absolute inductance [H]. + name: Optional display name. + """ + self._impedance_boundaries.append( + ImpedanceBoundaryConfig( + layer_a=layer_a, + layer_b=layer_b, + capacitance=capacitance, + resistance=resistance, + inductance=inductance, + name=name, + ) + ) + # ------------------------------------------------------------------------- # Material methods # ------------------------------------------------------------------------- @@ -1019,6 +1060,51 @@ def _generate_mesh_internal( mesh_stats=mesh_result.mesh_stats, ) + def print_mesh_stats(self) -> None: + """Print mesh statistics from the last mesh generation. + + Reports node/element counts and estimates solver DOFs based on + the solver polynomial order (default 2 for 2D, 1 for 3D). + """ + mr = self._last_mesh_result + if mr is None: + print("No mesh result. Call mesh() first.") # noqa: T201 + return + + stats = mr.mesh_stats or {} + groups = mr.groups or {} + + nodes = stats.get("nodes", 0) + elements = stats.get("elements", 0) + tets = stats.get("tetrahedra", 0) + + bbox = stats.get("bbox", {}) + if bbox: + dx = bbox.get("xmax", 0) - bbox.get("xmin", 0) + dy = bbox.get("ymax", 0) - bbox.get("ymin", 0) + dz = bbox.get("zmax", 0) - bbox.get("zmin", 0) + print(f" Bounding box: {dx:.3f} x {dy:.3f} x {dz:.3f} um") # noqa: T201 + + print(f" Nodes: {nodes:,}") # noqa: T201 + print(f" Elements: {elements:,}") # noqa: T201 + if tets: + print(f" Tetrahedra: {tets:,}") # noqa: T201 + + dom_volumes = groups.get("volumes", {}) + bdr_conductors = groups.get("conductor_surfaces", {}) + bdr_interfaces = groups.get("interface_surfaces", {}) + print(f" Domain groups: {len(dom_volumes)}") # noqa: T201 + print(f" Conductor surfaces:{len(bdr_conductors)}") # noqa: T201 + print(f" Interface surfaces:{len(bdr_interfaces)}") # noqa: T201 + + if elements and not tets: + p = 2 + nd_dofs_est = elements * p * (p + 1) + h1_dofs_est = elements * (p + 1) * (p + 2) // 2 + print(f" Est. ND-space DOFs (order {p}): ~{nd_dofs_est:,}") # noqa: T201 + print(f" Est. H1-space DOFs (order {p}): ~{h1_dofs_est:,}") # noqa: T201 + print(f" Est. total DOFs: ~{nd_dofs_est + h1_dofs_est:,}") # noqa: T201 + def _get_ports_for_preview(self, stack: LayerStack) -> list: """Get ports for preview.""" from gsim.palace.ports import extract_ports @@ -1445,6 +1531,12 @@ def write_config( stack = self._resolve_stack() electrostatic_config = getattr(self, "electrostatic", None) terminals = getattr(self, "terminals", None) + + # Thread impedance boundary configs through hints + hints = dict(self._hints) + if self._impedance_boundaries: + hints["_impedance_boundaries"] = self._impedance_boundaries + config_path = gen_write_config( mesh_result=self._last_mesh_result, stack=stack, @@ -1455,7 +1547,7 @@ def write_config( numerical_config=self.numerical, boundary_mode_config=getattr(self, "boundary_mode", None), absorbing_boundary=self.absorbing_boundary, - hints=self._hints, + hints=hints, electrostatic_config=electrostatic_config, terminals=terminals or [], ) @@ -1847,19 +1939,29 @@ def run_local( resolved_exe = bundled lib_dir = resolve_palace_library_dir() if verbose: - from gsim.palace.runtime import _palace_cpu_available as _cpu_avail + from gsim.palace.runtime import ( + _palace_cpu_available as _cpu_avail, + ) - source = "palace-toolkit-cpu" if _cpu_avail() else "PALACE_BIN / PATH" + source = ( + "palace-toolkit-cpu" + if _cpu_avail() + else "PALACE_BIN / PATH" + ) logger.info( "Palace binary: %s (source: %s)", - bundled, source, + bundled, + source, ) try: import subprocess as _sp - ver = _sp.run( + ver = _sp.run( # noqa: S603 [str(bundled), "--version"], - capture_output=True, text=True, timeout=10, check=False, + capture_output=True, + text=True, + timeout=10, + check=False, ) if ver.returncode == 0: logger.info(ver.stdout.strip()) diff --git a/src/gsim/palace/boundarymode.py b/src/gsim/palace/boundarymode.py index d1180508..fefe308f 100644 --- a/src/gsim/palace/boundarymode.py +++ b/src/gsim/palace/boundarymode.py @@ -63,6 +63,7 @@ class BoundaryModeSim(PalaceSimMixin, BaseModel): _stack_kwargs: dict[str, Any] = PrivateAttr(default_factory=dict) _pec_blocks: list = PrivateAttr(default_factory=list) _hints: dict[str, Any] = PrivateAttr(default_factory=dict) + _impedance_boundaries: list = PrivateAttr(default_factory=list) _airbox_config: dict[str, float] = PrivateAttr(default_factory=dict) # Internal state diff --git a/src/gsim/palace/driven.py b/src/gsim/palace/driven.py index 44e45166..866d1c3e 100644 --- a/src/gsim/palace/driven.py +++ b/src/gsim/palace/driven.py @@ -93,6 +93,7 @@ class DrivenSim(PalaceSimMixin, BaseModel): _airbox_config: dict[str, float] = PrivateAttr(default_factory=dict) _pec_blocks: list = PrivateAttr(default_factory=list) _hints: dict[str, Any] = PrivateAttr(default_factory=dict) + _impedance_boundaries: list = PrivateAttr(default_factory=list) # Internal state _output_dir: Path | None = PrivateAttr(default=None) diff --git a/src/gsim/palace/eigenmode.py b/src/gsim/palace/eigenmode.py index 53d1b723..2e4621b9 100644 --- a/src/gsim/palace/eigenmode.py +++ b/src/gsim/palace/eigenmode.py @@ -87,6 +87,7 @@ class EigenmodeSim(PalaceSimMixin, BaseModel): _airbox_config: dict[str, float] = PrivateAttr(default_factory=dict) _pec_blocks: list = PrivateAttr(default_factory=list) _hints: dict[str, Any] = PrivateAttr(default_factory=dict) + _impedance_boundaries: list = PrivateAttr(default_factory=list) # Internal state _output_dir: Path | None = PrivateAttr(default=None) diff --git a/src/gsim/palace/electrostatic.py b/src/gsim/palace/electrostatic.py index ec24ae26..0deb9d46 100644 --- a/src/gsim/palace/electrostatic.py +++ b/src/gsim/palace/electrostatic.py @@ -87,6 +87,7 @@ class ElectrostaticSim(PalaceSimMixin, BaseModel): _airbox_config: dict[str, float] = PrivateAttr(default_factory=dict) _pec_blocks: list = PrivateAttr(default_factory=list) _hints: dict[str, Any] = PrivateAttr(default_factory=dict) + _impedance_boundaries: list = PrivateAttr(default_factory=list) # Internal state _output_dir: Path | None = PrivateAttr(default=None) diff --git a/src/gsim/palace/mesh/config_generator.py b/src/gsim/palace/mesh/config_generator.py index 911ffc3c..85d0a9b6 100644 --- a/src/gsim/palace/mesh/config_generator.py +++ b/src/gsim/palace/mesh/config_generator.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import logging from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -13,6 +14,8 @@ from gsim.palace.ports.config import PortType +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from gsim.common.stack import LayerStack from gsim.palace.models import ( @@ -26,6 +29,94 @@ from gsim.palace.ports.config import PalacePort +def _resolve_impedance_boundaries( + hints: dict[str, Any] | None, + groups: dict, +) -> list[dict[str, object]]: + """Resolve impedance boundary configs from hints into Palace format entries. + + For interface-based entries (layer_a + layer_b), looks up the interface + physical group and divides absolute values by the stored curve length. + For attribute-based entries, uses the values as-is. + + Returns: + List of dicts suitable for the Palace ``Impedance`` boundary section. + """ + from gsim.palace.models.ports import ImpedanceBoundaryConfig + + raw = (hints or {}).get("_impedance_boundaries") + if not raw: + return [] + + entries: list[dict[str, object]] = [] + for ib in raw: + if not isinstance(ib, ImpedanceBoundaryConfig): + continue + + if ib.attributes is not None: + # Attribute-based: use values directly (already per-unit-length). + entry: dict[str, object] = { + "Attributes": sorted(ib.attributes), + } + if ib.resistance is not None: + entry["Rs"] = ib.resistance + if ib.inductance is not None: + entry["Ls"] = ib.inductance + if ib.capacitance is not None: + entry["Cs"] = ib.capacitance + entries.append(entry) + continue + + # Interface-based: look up the interface physical group. + candidates = [ + f"interface_{ib.layer_a}_{ib.layer_b}", + f"interface_{ib.layer_b}_{ib.layer_a}", + ] + group_info = None + for name in candidates: + info = groups.get("interface_surfaces", {}).get(name) + if info is not None: + group_info = info + break + + if group_info is None: + logger.warning( + "Impedance boundary interface '%s' / '%s' not found in mesh. " + "Available interfaces: %s", + ib.layer_a, + ib.layer_b, + ", ".join(sorted(groups.get("interface_surfaces", {}).keys())), + ) + continue + + entry = { + "Attributes": [group_info["phys_group"]], + } + + # Convert absolute values to per-unit-length using the stored length. + length_um = group_info.get("length_um", 0.0) + if length_um <= 0.0: + logger.warning( + "Interface '%s' / '%s' has zero or unknown length; " + "using absolute values directly.", + ib.layer_a, + ib.layer_b, + ) + length_um = 1.0 # fallback: treat as 1 um + + length_m = length_um * 1e-6 + if ib.resistance is not None: + entry["Rs"] = ib.resistance / length_m + if ib.inductance is not None: + entry["Ls"] = ib.inductance / length_m + if ib.capacitance is not None: + entry["Cs"] = ib.capacitance / length_m + + entries.append(entry) + + return entries + + def generate_palace_config( groups: dict, ports: list[PalacePort], @@ -302,7 +393,7 @@ def _lookup_material(name: str) -> dict[str, object]: pec_attrs: list[int] = [ info["phys_group"] for info in groups.get("pec_surfaces", {}).values() ] - boundaries: dict[str, object] + boundaries: dict[str, Any] is_electrostatic = simulation_type in ("electrostatic", "electrostatics") @@ -377,7 +468,7 @@ def _via_touches(via_name: str, conductor_layer_name: str) -> bool: ground_attrs.extend(via_pgs) break - boundaries: dict[str, object] = { + boundaries: dict[str, Any] = { "Terminal": terminal_entries, } if ground_attrs: @@ -508,7 +599,7 @@ def _via_touches(via_name: str, conductor_layer_name: str) -> bool: synthetic_idx += 1 lumped_ports.extend(passive_reactive_ports) - boundaries: dict[str, object] = { + boundaries: dict[str, Any] = { "Conductivity": conductors, "LumpedPort": lumped_ports, "WavePort": wave_ports, @@ -574,11 +665,17 @@ def _via_touches(via_name: str, conductor_layer_name: str) -> bool: ], } + # Process impedance boundaries from hints (interface-based or attribute-based) + impedance_entries = _resolve_impedance_boundaries(hints, groups) + if impedance_entries: + boundaries.setdefault("Impedance", []).extend(impedance_entries) + config["Boundaries"] = boundaries - # Merge any extra hints into the config + # Merge any extra hints into the config (strip internal keys first) if hints: - config.update(hints) + clean_hints = {k: v for k, v in hints.items() if not k.startswith("_")} + config.update(clean_hints) # Write config file config_path = output_path / "config.json" diff --git a/src/gsim/palace/mesh/generator.py b/src/gsim/palace/mesh/generator.py index 783d5892..211ce671 100644 --- a/src/gsim/palace/mesh/generator.py +++ b/src/gsim/palace/mesh/generator.py @@ -364,6 +364,7 @@ def _generate_native_boundarymode_groups( "conductor_surfaces": {}, "pec_surfaces": {}, "port_surfaces": {}, + "interface_surfaces": {}, "boundary_surfaces": {}, "refinement_lines": {}, } @@ -499,33 +500,44 @@ def _is_conductive(value: float | list[float] | None) -> bool: assigned: set[int] = set() + # First pass: assign volume physical groups to dielectrics. + dielectrics: dict[str, list[int]] = {} + metals: dict[str, list[int]] = {} for layer_name, surface_tags in sorted(layer_surfaces.items()): sorted_tags = sorted(surface_tags) layer = stack.layers.get(layer_name) is_metal_like = layer is not None and layer.layer_type in {"conductor", "via"} - - # Always consume the conductor interior from air assignment, but do - # not export it as a 2D domain material. assigned.update(sorted_tags) + if is_metal_like: + metals[layer_name] = sorted_tags + else: + dielectrics[layer_name] = sorted_tags - if not is_metal_like: - pg = gmsh.model.addPhysicalGroup(2, sorted_tags) - gmsh.model.setPhysicalName(2, pg, layer_name) - - entry: dict[str, object] = { - "phys_group": pg, - "tags": sorted_tags, - } - # Only layer-backed dielectric polygons are shaped dielectrics. - # Background dielectric slabs (e.g. sio2/sin material regions) - # should be treated as regular material domains. - if layer is not None and layer.layer_type == "dielectric": - entry["is_shaped_dielectric"] = True - if layer is not None and layer.layer_type == "via": - entry["is_via"] = True - groups["volumes"][layer_name] = entry - continue - + for layer_name, sorted_tags in dielectrics.items(): + layer = stack.layers.get(layer_name) + pg = gmsh.model.addPhysicalGroup(2, sorted_tags) + gmsh.model.setPhysicalName(2, pg, layer_name) + entry: dict[str, object] = { + "phys_group": pg, + "tags": sorted_tags, + } + if layer is not None and layer.layer_type == "dielectric": + entry["is_shaped_dielectric"] = True + if layer is not None and layer.layer_type == "via": + entry["is_via"] = True + groups["volumes"][layer_name] = entry + + # Build the set of all surface tags that have a volume physical group + # (all dielectrics are now assigned). + vol_pg_surfaces: set[int] = set() + for pg_dim, pg_tag in gmsh.model.getPhysicalGroups(): + if pg_dim == 2: + ents = gmsh.model.getEntitiesForPhysicalGroup(2, pg_tag) + vol_pg_surfaces.update(int(e) for e in ents) + + # Second pass: process conductor/via boundary curves (all dielectrics now + # have PGs, so the adjacency filter correctly preserves shared edges). + for layer_name, sorted_tags in metals.items(): pec_curves: set[int] = set() for stag in sorted_tags: try: @@ -549,12 +561,6 @@ def _is_conductive(value: float | list[float] | None) -> bool: # segfault in MFEM's GetBdrElementFace. Filter them out by # checking gmsh adjacencies: keep a curve only if at least one # adjacent surface actually has a volume physical group. - # Build a set of all surface tags that have a volume PG. - vol_pg_surfaces: set[int] = set() - for pg_dim, pg_tag in gmsh.model.getPhysicalGroups(): - if pg_dim == 2: - ents = gmsh.model.getEntitiesForPhysicalGroup(2, pg_tag) - vol_pg_surfaces.update(int(e) for e in ents) filtered_curves: set[int] = set() n_internal = 0 for ctag in pec_curves: @@ -563,12 +569,10 @@ def _is_conductive(value: float | list[float] | None) -> bool: except Exception: filtered_curves.add(ctag) continue - # adj[1] = dim-2 entities (surfaces) adjacent to this curve adj_surfaces = set(adj[1]) if len(adj) > 1 else set() if not adj_surfaces: filtered_curves.add(ctag) continue - # Keep the curve if at least one adjacent surface has a PG if adj_surfaces & vol_pg_surfaces: filtered_curves.add(ctag) else: @@ -632,6 +636,56 @@ def _is_conductive(value: float | list[float] | None) -> bool: "tags": sorted(refinement_curves) } + # --- Dielectric-dielectric interface curves ------------------------------- + # Build per-volume boundary curves, then find shared curves between pairs + # of dielectric volumes. Each shared curve is assigned a physical group + # named "interface__" and stored under + # groups["interface_surfaces"]. + vol_boundary_curves: dict[str, set[int]] = {} + for layer_name, vol_info in groups["volumes"].items(): + curves: set[int] = set() + for stag in vol_info.get("tags", []): + try: + bounds = gmsh.model.getBoundary( + [(2, int(stag))], + combined=False, + oriented=False, + recursive=False, + ) + except Exception: + continue + for dim, ctag in bounds: + if dim == 1: + curves.add(int(ctag)) + if curves: + vol_boundary_curves[layer_name] = curves + + vol_names = list(vol_boundary_curves.keys()) + for i, l1 in enumerate(vol_names): + for l2 in vol_names[i + 1 :]: + shared = vol_boundary_curves[l1] & vol_boundary_curves[l2] + if shared: + name = f"interface_{l1}_{l2}" + pg = gmsh.model.addPhysicalGroup(1, sorted(shared)) + gmsh.model.setPhysicalName(1, pg, name) + + # Compute approximate total curve length from bounding boxes + total_length = 0.0 + for ctag in shared: + try: + bb = gmsh.model.getBoundingBox(1, int(ctag)) + dx = bb[3] - bb[0] + dy = bb[4] - bb[1] + total_length += math.sqrt(dx * dx + dy * dy) + except Exception: + pass + + groups.setdefault("interface_surfaces", {})[name] = { + "phys_group": pg, + "tags": sorted(shared), + "length_um": total_length, + } + outer_curves: set[int] = set() for stag in outer_parts: try: @@ -1054,7 +1108,7 @@ def generate_mesh( refinement_lines, aggressive_size, max_mesh_size, - sampling=400, + sampling=200, dist_max=max_mesh_size * 0.5, ) gmsh_utils.finalize_mesh_fields([field_id]) diff --git a/src/gsim/palace/models/__init__.py b/src/gsim/palace/models/__init__.py index c0905072..2e44390c 100644 --- a/src/gsim/palace/models/__init__.py +++ b/src/gsim/palace/models/__init__.py @@ -22,6 +22,7 @@ from gsim.palace.models.pec import PECBlockConfig from gsim.palace.models.ports import ( CPWPortConfig, + ImpedanceBoundaryConfig, PortConfig, TerminalConfig, WavePortConfig, @@ -45,6 +46,7 @@ "EigenmodeConfig", "ElectrostaticConfig", "GeometryConfig", + "ImpedanceBoundaryConfig", "MagnetostaticConfig", "MaterialConfig", "MeshConfig", diff --git a/src/gsim/palace/models/ports.py b/src/gsim/palace/models/ports.py index e66c1c3e..a066369b 100644 --- a/src/gsim/palace/models/ports.py +++ b/src/gsim/palace/models/ports.py @@ -133,6 +133,56 @@ class TerminalConfig(BaseModel): layer: str +class ImpedanceBoundaryConfig(BaseModel): + """Configuration for an Impedance boundary on a dielectric-dielectric interface. + + Specifies a surface impedance boundary condition (Rs, Ls, Cs) applied to + the shared boundary curve between two dielectric layers. Capacitance, + resistance, and inductance values are given as *absolute* quantities and + are divided by the interface curve length internally to obtain the + per-unit-length values (Rs, Ls, Cs) expected by Palace. + + Alternatively, ``attributes`` can be specified directly (with Rs/Ls/Cs + already in per-unit-length form) for cases where the user already knows + the Palace boundary attribute numbers. + + Attributes: + layer_a: Name of the first dielectric layer. + layer_b: Name of the second dielectric layer. + capacitance: Absolute capacitance [F] (divided by curve length). + resistance: Absolute resistance [Ohm] (divided by curve length). + inductance: Absolute inductance [H] (divided by curve length). + attributes: Direct Palace boundary attribute list (bypasses layer lookup). + When set, Rs/Ls/Cs must be in per-unit-length values. + name: Optional display name. + """ + + model_config = ConfigDict(validate_assignment=True) + + layer_a: str | None = None + layer_b: str | None = None + capacitance: float | None = Field(default=None, ge=0, description="Capacitance [F]") + resistance: float | None = Field(default=None, ge=0, description="Resistance [Ohm]") + inductance: float | None = Field(default=None, ge=0, description="Inductance [H]") + attributes: list[int] | None = None + name: str | None = None + + @model_validator(mode="after") + def _validate_target(self) -> Self: + """Validate that exactly one of (layer_a+layer_b) or attributes is set.""" + if (self.layer_a is None or self.layer_b is None) and self.attributes is None: + raise ValueError("Either layer_a+layer_b or attributes must be provided") + if ( + self.layer_a is not None or self.layer_b is not None + ) and self.attributes is not None: + raise ValueError("Cannot specify both layer_a/layer_b and attributes") + if self.layer_a is not None and self.layer_b is None: + raise ValueError("Both layer_a and layer_b must be provided together") + if self.layer_a is None and self.layer_b is not None: + raise ValueError("Both layer_a and layer_b must be provided together") + return self + + class WavePortConfig(BaseModel): """Configuration for a wave port (domain boundary with mode solving). @@ -171,6 +221,7 @@ class WavePortConfig(BaseModel): __all__ = [ "CPWPortConfig", + "ImpedanceBoundaryConfig", "PortConfig", "TerminalConfig", "WavePortConfig", diff --git a/src/gsim/palace/plane_section.py b/src/gsim/palace/plane_section.py new file mode 100644 index 00000000..1c49ae97 --- /dev/null +++ b/src/gsim/palace/plane_section.py @@ -0,0 +1,162 @@ +"""2D cross-section ('plane section') visualisation. + +Given a list of axis-aligned rectangles / polygons produced by +``gsim.common.cross_section.extract_plane_section`` (e.g. an ``x=0`` cut +through a component), this module draws a matplotlib 2D cross-section where +every region is coloured by its layer / physical-group name. + +This is the reusable, post-processing version of the inline plot that used to +live in ``nbs/palace_2d_twmzm.ipynb``. It is solver-agnostic with respect to +the *section* data (only the small dataclass types from +``gsim.common.cross_section`` are required), so it can be used to visualise +the physical groups of any Palace cross-section. + +Supported inputs: + - ``Rect2D`` (XZ cut at fixed Y; horizontal axis = x, vertical = z) + - ``RectYZ2D`` (YZ cut at fixed X; horizontal axis = y, vertical = z) + - ``PolygonXY2D`` (XY cut at fixed Z; polygons in the x-y plane) +""" + +from __future__ import annotations + +from typing import Any, Literal + +import matplotlib.pyplot as plt +from matplotlib.axes import Axes +from matplotlib.patches import Polygon, Rectangle + +from gsim.common.cross_section import PolygonXY2D, Rect2D, RectYZ2D + +__all__ = ["plot_plane_section"] + +_LINE_COLOR = "k" +_LINE_WIDTH = 0.5 + + +def _rect_props( + rect: Rect2D | RectYZ2D, +) -> tuple[tuple[float, float], float, float, tuple[str, str]]: + """Return ``((h0, h1), v0, v1, (h_label, v_label))`` for a rectangle.""" + if isinstance(rect, RectYZ2D): + return (rect.y0, rect.y1), rect.zmin, rect.zmax, ("y (um)", "z (um)") + return (rect.x0, rect.x1), rect.zmin, rect.zmax, ("x (um)", "z (um)") + + +def _color_map(names: list[str], colors: dict[str, Any] | None) -> dict[str, Any]: + """Merge user colours with deterministic ``tab10`` fallbacks.""" + if colors is None: + colors = {} + cmap = plt.colormaps.get_cmap("tab10") + for i, name in enumerate(dict.fromkeys(names)): + colors.setdefault(name, cmap(i % 10)) + return colors + + +def plot_plane_section( + section: list[Any], + *, + colors: dict[str, Any] | None = None, + h_range: tuple[float, float] | None = None, + v_range: tuple[float, float] | None = None, + title: str | None = None, + figsize: tuple[float, float] = (10, 4), + aspect: Literal["auto", "equal"] | float = "equal", + legend: bool = True, + ax: Axes | None = None, +) -> Axes: + """Plot a 2D cross-section coloured by layer / physical-group name. + + Args: + section: Regions from ``extract_plane_section`` (``Rect2D``, + ``RectYZ2D`` or ``PolygonXY2D``). A single mixed type is + expected per call. + colors: Optional ``{layer_name: colour}`` override. When omitted, + colours are drawn deterministically from the ``tab10`` colormap. + h_range: ``(h_min, h_max)`` limits for the horizontal axis. Auto + from the section when ``None``. + v_range: ``(v_min, v_max)`` limits for the vertical axis. Auto + from the section when ``None``. + title: Optional plot title. + figsize: Figure size (ignored if *ax* is supplied). + aspect: Matplotlib aspect ratio for the axes (e.g. ``"equal"``). + legend: Whether to show the per-group legend. + ax: Optional existing axes to draw on. If ``None`` a new figure is + created and shown. + + Returns: + The ``plt.Axes`` the section was drawn on. + """ + if ax is None: + _, ax = plt.subplots(figsize=figsize) + + names = [getattr(r, "layer_name", str(r)) for r in section] + color_map = _color_map(names, colors) + + h_lims: list[float] = [] + v_lims: list[float] = [] + h_label: str | None = None + v_label: str | None = None + + for r in section: + name = getattr(r, "layer_name", str(r)) + color = color_map.get(name, "#dddddd") + + if isinstance(r, (Rect2D, RectYZ2D)): + (h0, h1), v0, v1, (hl, vl) = _rect_props(r) + h_label = hl + v_label = vl + h_lims += [h0, h1] + v_lims += [v0, v1] + ax.add_patch( + Rectangle( + (h0, v0), + h1 - h0, + v1 - v0, + facecolor=color, + edgecolor=_LINE_COLOR, + linewidth=_LINE_WIDTH, + alpha=0.8, + label=name, + ) + ) + elif isinstance(r, PolygonXY2D): + h_label = "x (um)" + v_label = "y (um)" + h_lims += [p[0] for p in r.exterior] + v_lims += [p[1] for p in r.exterior] + ax.add_patch( + Polygon( + r.exterior, + facecolor=color, + edgecolor=_LINE_COLOR, + linewidth=_LINE_WIDTH, + alpha=0.8, + label=name, + ) + ) + else: + msg = f"Unsupported section element: {type(r)!r}" + raise TypeError(msg) + + if h_range is not None: + ax.set_xlim(*h_range) + elif h_lims: + ax.set_xlim(min(h_lims), max(h_lims)) + if v_range is not None: + ax.set_ylim(*v_range) + elif v_lims: + ax.set_ylim(min(v_lims), max(v_lims)) + + if h_label is not None: + ax.set_xlabel(h_label) + if v_label is not None: + ax.set_ylabel(v_label) + ax.set_aspect(aspect) + ax.set_title(title or "Cross-section physical groups") + + if legend: + handles, labels = ax.get_legend_handles_labels() + by_label = dict(zip(labels, handles, strict=True)) + ax.legend(by_label.values(), by_label.keys(), loc="upper right", fontsize=8) + + return ax diff --git a/tests/common/test_cross_section.py b/tests/common/test_cross_section.py index 53cd865e..8b8fd80d 100644 --- a/tests/common/test_cross_section.py +++ b/tests/common/test_cross_section.py @@ -11,11 +11,13 @@ PolygonXY2D, Rect2D, RectYZ2D, + build_doped_cross_section, extract_plane_section, extract_xy_polygons, extract_xz_rectangles, extract_yz_rectangles, ) +from gsim.common.stack.doping import make_doping_profile def _layer( @@ -307,3 +309,106 @@ def test_extract_plane_section_dispatch(self): assert len(z_polys) == 1 assert isinstance(z_polys[0], PolygonXY2D) + + +def _centered_rect(wx: float, wy: float, layer: tuple[int, int]): + import gdsfactory as gf + + r = gf.Component() + r << gf.c.rectangle((wx, wy), centered=True, layer=layer) + return r + + +class TestBuildDopedCrossSection: + """Tests for build_doped_cross_section() using the active PDK stack.""" + + def _build_component(self): + import gdsfactory as gf + + LAYER = gf.gpdk.LAYER + comp = gf.Component() + wg = comp << _centered_rect(10.0, 0.4, LAYER.WG) + wg.y = -20.0 + return comp, LAYER + + def _doping_result(self, comp, rib_center_y=-20.0): + return make_doping_profile( + comp, + length=10.0, + rib_center_y=rib_center_y, + rib_width=0.4, + profile={"upper": [(2.0, 2e4)], "lower": [(2.0, 2e4)]}, + sides={ + "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, + "lower": { + "base_layer": (24, 0), + "name_prefix": "npp_slab_", + "sign": -1, + }, + }, + zmin=0.0, + zmax=0.09, + ) + + def test_builds_stack_with_doping_and_rib_layers(self): + comp, LAYER = self._build_component() + doping = self._doping_result(comp) + + stack, section = build_doped_cross_section( + comp, + axis="x", + value=0.0, + substrate_thickness=2.0, + include_substrate=False, + doping=doping, + metal1=(1.1, 1.0), + rib_layers=[ + ("p_rib", LAYER.P, 1.6e3), + ("n_rib", LAYER.N, 1.6e3), + ], + permittivity=11.9, + fmax=200e9, + verbose=False, + ) + + assert "pp_slab_0" in stack.layers + assert "npp_slab_0" in stack.layers + assert "p_rib" in stack.layers + assert "n_rib" in stack.layers + + p_rib = stack.layers["p_rib"] + assert p_rib.gds_layer == tuple(LAYER.P) + assert p_rib.zmax == pytest.approx(0.22) + assert p_rib.thickness == pytest.approx(0.22) + assert p_rib.material == "p_rib" + + layers = {r.layer_name for r in section} + assert {"core", "pp_slab_0", "npp_slab_0"} <= layers + + def test_metal1_override_applied(self): + comp, _LAYER = self._build_component() + stack, _ = build_doped_cross_section( + comp, + axis="x", + value=0.0, + substrate_thickness=2.0, + metal1=(1.1, 1.0), + verbose=False, + ) + m1 = stack.layers["metal1"] + assert m1.zmin == pytest.approx(1.1) + assert m1.zmax == pytest.approx(2.1) + assert m1.thickness == pytest.approx(1.0) + + def test_no_doping_no_rib_layers(self): + comp, _LAYER = self._build_component() + stack, _ = build_doped_cross_section( + comp, + axis="x", + value=0.0, + substrate_thickness=2.0, + verbose=False, + ) + assert "p_rib" not in stack.layers + assert "pp_slab_0" not in stack.layers + assert "core" in stack.layers diff --git a/tests/common/test_doping_materials.py b/tests/common/test_doping_materials.py new file mode 100644 index 00000000..f610e068 --- /dev/null +++ b/tests/common/test_doping_materials.py @@ -0,0 +1,75 @@ +"""Tests for the generic doped-material builders. + +These helpers must be free of hardcoded / PDK-specific values so they can be +reused across projects. +""" + +from __future__ import annotations + +import pytest + +from gsim.common.stack.materials import ( + DispersionModel, + ValidityRange, + make_doped_material, + make_doped_materials, +) + + +class TestMakeDopedMaterial: + def test_defaults(self): + mat = make_doped_material("p_rib", 1.6e3) + assert mat.conductivity == 1.6e3 + assert mat.permittivity == 11.9 + assert len(mat.dispersion_models) == 1 + model = mat.dispersion_models[0] + assert isinstance(model, DispersionModel) + assert model.type == "constant" + assert model.permittivity == 11.9 + assert model.validity == ValidityRange(valid_frequency=(0, 200e9)) + assert model.source == "doped Si (p_rib) -- Drude sigma" + + def test_custom_values(self): + mat = make_doped_material( + "a", 5e4, permittivity=12.0, fmax=50e9, source="custom" + ) + assert mat.permittivity == 12.0 + assert mat.dispersion_models[0].validity == ValidityRange( + valid_frequency=(0, 50e9) + ) + assert mat.dispersion_models[0].source == "custom" + + def test_resolves_to_conductive_at_rf(self): + mat = make_doped_material("x", 2e4) + resolved = mat.evaluate_at_wavelength(1000.0) # RF-ish wavelength + assert resolved.conductivity == 2e4 + assert resolved.behavior == "conductive" + + +class TestMakeDopedMaterials: + def test_dict_input(self): + mats = make_doped_materials({"pp_slab_0": 2e4, "pp_slab_1": 8e4}) + assert set(mats) == {"pp_slab_0", "pp_slab_1"} + assert mats["pp_slab_0"].conductivity == 2e4 + assert mats["pp_slab_1"].conductivity == 8e4 + + def test_two_tuple_input(self): + mats = make_doped_materials([("n_rib", 1.6e3), ("p_rib", 1.6e3)]) + assert mats["n_rib"].permittivity == 11.9 + assert mats["n_rib"].conductivity == 1.6e3 + + def test_four_tuple_input(self): + mats = make_doped_materials([("r1", 13.0, 7e3, "doc")]) + assert mats["r1"].permittivity == 13.0 + assert mats["r1"].conductivity == 7e3 + assert mats["r1"].dispersion_models[0].source == "doc" + + def test_invalid_entry(self): + with pytest.raises(ValueError): + make_doped_materials([("only_name",)]) + + def test_returned_materials_resolvable(self): + mats = make_doped_materials({"a": 1e4}) + # The generated object behaves like any database material. + for mat in mats.values(): + assert mat.evaluate_at_frequency(1e9).behavior == "conductive" diff --git a/tests/common/test_doping_profile.py b/tests/common/test_doping_profile.py new file mode 100644 index 00000000..2e8b86dc --- /dev/null +++ b/tests/common/test_doping_profile.py @@ -0,0 +1,127 @@ +"""Tests for the generic doping-profile helper (gsim.common.stack.doping).""" + +from __future__ import annotations + +import gdsfactory as gf +import pytest + +from gsim.common.stack.doping import make_doping_profile + + +@pytest.fixture +def sides(): + return { + "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, + "lower": {"base_layer": (24, 0), "name_prefix": "npp_slab_", "sign": -1}, + } + + +def _profile(): + return { + "upper": [(2.0, 2e4), (2.0, 8e4)], + "lower": [(2.0, 2e4), (2.0, 8e4)], + } + + +def test_make_doping_profile_basic(sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=-20.0, + rib_width=0.4, + profile=_profile(), + sides=sides, + zmin=0.0, + zmax=0.09, + ) + + expected = {"pp_slab_0", "pp_slab_1", "npp_slab_0", "npp_slab_1"} + assert set(result["layer_specs"]) == expected + assert set(result["materials"]) == set(result["layer_specs"]) + + # Centres: regions start at the rib edges and are contiguous. + upper = result["centres"]["upper"] + lower = result["centres"]["lower"] + rib_upper_edge = -20.0 + 0.4 / 2 + rib_lower_edge = -20.0 - 0.4 / 2 + assert upper[0] == pytest.approx(rib_upper_edge + 2.0 / 2) + assert upper[1] == pytest.approx(rib_upper_edge + 2.0 + 2.0 / 2) + assert lower[0] == pytest.approx(rib_lower_edge - 2.0 / 2) + assert lower[1] == pytest.approx(rib_lower_edge - 2.0 - 2.0 / 2) + + +def test_make_doping_profile_layer_spec(sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_profile(), + sides=sides, + zmin=0.0, + zmax=0.09, + ) + + layer = result["layer_specs"]["pp_slab_0"] + assert layer.gds_layer == (23, 0) + assert layer.zmin == 0.0 + assert layer.zmax == 0.09 + assert layer.thickness == 0.09 + assert layer.material == "pp_slab_0" + + # Second region uses an offset datatype within the base layer. + assert result["layer_specs"]["pp_slab_1"].gds_layer == (23, 1) + assert result["layer_specs"]["npp_slab_0"].gds_layer == (24, 0) + + +def test_make_doping_profile_materials(sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_profile(), + sides=sides, + zmin=0.0, + zmax=0.09, + ) + assert result["materials"]["pp_slab_0"].conductivity == 2e4 + assert result["materials"]["pp_slab_1"].conductivity == 8e4 + assert result["materials"]["npp_slab_1"].conductivity == 8e4 + + +def test_make_doping_profile_empty_side(sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile={"upper": [], "lower": [(1.0, 1e4)]}, + sides=sides, + zmin=0.0, + zmax=0.09, + ) + assert "pp_slab_0" not in result["layer_specs"] + assert "npp_slab_0" in result["layer_specs"] + assert result["centres"]["upper"] == [] + + +def test_make_doping_profile_geometry_added(sides): + comp = gf.Component() + make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_profile(), + sides=sides, + zmin=0.0, + zmax=0.09, + ) + # 4 rectangles drawn (2 upper + 2 lower). + total = len(comp.get_polygons()) + assert total == 4 diff --git a/tests/palace/test_plane_section_viz.py b/tests/palace/test_plane_section_viz.py new file mode 100644 index 00000000..95e21443 --- /dev/null +++ b/tests/palace/test_plane_section_viz.py @@ -0,0 +1,102 @@ +"""Tests for gsim.palace.plane_section (2D cross-section physical-group viz).""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest +from matplotlib.patches import Rectangle + +from gsim.common.cross_section import PolygonXY2D, Rect2D, RectYZ2D +from gsim.palace.plane_section import plot_plane_section + + +def _rect_yz(y0, y1, z0, z1, name="rib"): + return RectYZ2D(y0=y0, y1=y1, zmin=z0, zmax=z1, layer_name=name, material=name) + + +def _rect_xz(x0, x1, z0, z1, name="rib"): + return Rect2D(x0=x0, x1=x1, zmin=z0, zmax=z1, layer_name=name, material=name) + + +class TestPlotPlaneSection: + def test_rect_yz(self): + fig, ax = plt.subplots() + out = plot_plane_section( + [_rect_yz(-20, -19.8, 0, 0.22, "p_rib")], ax=ax, legend=False + ) + assert out is ax + assert len(ax.patches) == 1 + patch = ax.patches[0] + assert isinstance(patch, Rectangle) + assert patch.get_x() == -20 + assert patch.get_y() == 0 + assert patch.get_width() == pytest.approx(0.2) + assert patch.get_height() == 0.22 + assert ax.get_xlabel() == "y (um)" + assert ax.get_ylabel() == "z (um)" + plt.close(fig) + + def test_rect_xz(self): + fig, ax = plt.subplots() + plot_plane_section([_rect_xz(0, 10, 0, 1, "wg")], ax=ax, legend=False) + assert ax.get_xlabel() == "x (um)" + assert ax.get_ylabel() == "z (um)" + plt.close(fig) + + def test_polygon_xy(self): + fig, ax = plt.subplots() + poly = PolygonXY2D( + exterior=((0, 0), (1, 0), (1, 1), (0, 1)), + holes=(), + layer_name="core", + material="si", + ) + plot_plane_section([poly], ax=ax, legend=False) + assert ax.get_xlabel() == "x (um)" + assert ax.get_ylabel() == "y (um)" + assert len(ax.patches) == 1 + plt.close(fig) + + def test_custom_colors(self): + fig, ax = plt.subplots() + plot_plane_section( + [_rect_yz(-1, 1, 0, 0.2, "a"), _rect_yz(2, 3, 0, 0.1, "b")], + colors={"a": "red", "b": "blue"}, + ax=ax, + legend=False, + ) + assert ax.patches[0].get_facecolor()[:3] == (1.0, 0.0, 0.0) + assert ax.patches[1].get_facecolor()[:3] == (0.0, 0.0, 1.0) + plt.close(fig) + + def test_auto_limits_and_ranges(self): + fig, ax = plt.subplots() + plot_plane_section( + [_rect_yz(1, 2, 3, 4, "a")], + h_range=(0, 10), + v_range=(-1, 5), + ax=ax, + legend=False, + ) + assert ax.get_xlim() == (0, 10) + assert ax.get_ylim() == (-1, 5) + plt.close(fig) + + def test_legend_deduplicated(self): + fig, ax = plt.subplots() + plot_plane_section( + [_rect_yz(-1, 1, 0, 0.2, "a"), _rect_yz(-2, -1.5, 0, 0.1, "a")], + ax=ax, + ) + legend = ax.get_legend() + assert legend is not None + labels = [t.get_text() for t in legend.get_texts()] + assert labels.count("a") == 1 + plt.close(fig) + + def test_unsupported_element(self): + with pytest.raises(TypeError): + plot_plane_section([object()]) From 52b7f719ca3c680edc6c87b59bc166e5a47b02f1 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Wed, 5 Aug 2026 19:25:53 -0300 Subject: [PATCH 4/6] fix: align tests and CI with changed APIs and defaults (apptainer default, PyVista return type, ty) --- nbs/palace_2d_twmzm.ipynb | 9 +- nbs/palace_2d_twmzm.py | 9 +- pyproject.toml | 12 ++ src/gsim/common/stack/materials.py | 4 +- src/gsim/palace/base.py | 142 ++++++++++++++++++- src/gsim/palace/field_viz.py | 65 +++++---- src/gsim/palace/mesh/generator.py | 2 +- src/gsim/viz.py | 206 +++++++++++++++++++++++----- tests/common/conftest.py | 16 ++- tests/common/test_cross_section.py | 119 +++++++++++++++- tests/common/test_doping_profile.py | 127 ----------------- tests/palace/test_field_viz.py | 14 +- tests/palace/test_run_local.py | 164 +++++++++++++++++++++- tests/test_viz_mesh.py | 76 ++++++++++ 14 files changed, 750 insertions(+), 215 deletions(-) delete mode 100644 tests/common/test_doping_profile.py diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index 386ec5ea..309166cf 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -420,6 +420,9 @@ "outputs": [], "source": [ "# -- RF simulation (50 GHz) ------------------------------------------------\n", + "# 2D mode analysis defaults to a single MPI rank + OpenMP threads: Palace's\n", + "# SuperLU_DIST direct solve does not scale on small 2D problems (16 MPI\n", + "# ranks can effectively hang), so MPI is unnecessary here.\n", "results = sim.run_local(verbose=True)\n", "results.print()" ] @@ -578,8 +581,10 @@ "\n", "**Next steps (user action):**\n", "1. Verify the zoomed cross-section plot shows the rib (centred at y=-20), PN junction, graded doping, and vias.\n", - "2. Run `sim.run_local(num_processes=4, verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`).\n", - "3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode.\n", + "2. Run `sim.run_local(verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). 2D\n", + " mode analysis defaults to a single MPI rank + OpenMP threads; pass `num_processes=1` explicitly if you want\n", + " to be explicit about it.\n", + "3. Run `sim_opt.run_local(verbose=True)` for the optical mode.\n", "4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups.\n" ] }, diff --git a/nbs/palace_2d_twmzm.py b/nbs/palace_2d_twmzm.py index 731c447f..ee4bd327 100644 --- a/nbs/palace_2d_twmzm.py +++ b/nbs/palace_2d_twmzm.py @@ -340,6 +340,9 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: # %% # -- RF simulation (50 GHz) ------------------------------------------------ +# 2D mode analysis defaults to a single MPI rank + OpenMP threads: Palace's +# SuperLU_DIST direct solve does not scale on small 2D problems (16 MPI +# ranks can effectively hang), so MPI is unnecessary here. results = sim.run_local(verbose=True) results.print() @@ -461,8 +464,10 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: # # **Next steps (user action):** # 1. Verify the zoomed cross-section plot shows the rib (centred at y=-20), PN junction, graded doping, and vias. -# 2. Run `sim.run_local(num_processes=4, verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). -# 3. Run `sim_opt.run_local(num_processes=4, verbose=True)` for the optical mode. +# 2. Run `sim.run_local(verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). 2D +# mode analysis defaults to a single MPI rank + OpenMP threads; pass `num_processes=1` explicitly if you want +# to be explicit about it. +# 3. Run `sim_opt.run_local(verbose=True)` for the optical mode. # 4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups. # diff --git a/pyproject.toml b/pyproject.toml index b942f343..13035258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,18 @@ dependencies = [ palace-toolkit-cpu = [ "palacetoolkit-palace-cpu @ https://github.com/EpsilonForge/PalaceToolkit/releases/download/palace-cpu-v0.17.0/palacetoolkit_palace_cpu-0.17.0-py3-none-linux_x86_64.whl ; sys_platform == 'linux' and platform_machine == 'x86_64'", ] +# Interactive 3-D visualization (PyVista + trame server rendering) with the +# extra packages needed for reliable headless and notebook use. +visualize = [ + "jupyter-server-proxy>=4.1.0", + "nest-asyncio", + "pyvirtualdisplay>=3.0.0", + "trame>=3.6.0,<4", + "trame-client>=3.6.0,<4", + "trame-server>=3.6.0,<4", + "trame-vtk>=2.8.0,<3", + "trame-vuetify>=2.7.0,<3", +] [dependency-groups] dev = [ diff --git a/src/gsim/common/stack/materials.py b/src/gsim/common/stack/materials.py index f9da42b4..2a619a88 100644 --- a/src/gsim/common/stack/materials.py +++ b/src/gsim/common/stack/materials.py @@ -659,7 +659,7 @@ def make_doped_materials( else: for entry in entries: if len(entry) == 2: - name, sigma = cast(tuple[str, float], entry) # type: ignore[redundant-cast] + name, sigma = entry # type: ignore[arg-type] items.append( ( name, @@ -669,7 +669,7 @@ def make_doped_materials( ) ) elif len(entry) == 4: - items.append(cast(tuple[str, float, float, str], entry)) # type: ignore[redundant-cast] + items.append(entry) # type: ignore[arg-type] else: msg = ( "Entries must be (name, sigma) or (name, permittivity, " diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index f243cdcd..70e11460 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -58,6 +58,91 @@ def _count_physical_cpus() -> int: return os.cpu_count() or 1 +# Minimum estimated unknowns per MPI rank for SuperLU_DIST to factor +# efficiently. Below this, the 2D block-cyclic processor grid is dominated by +# communication/fill overhead and the factorization degrades dramatically +# (an effective hang on small 2D meshes at 8-16 ranks). +_MIN_DOFS_PER_RANK = 50_000 +# Absolute ceiling on MPI ranks for 3D problems that use a sparse direct +# solver. 2D (BoundaryMode) problems never use MPI: see _recommend_parallel. +_MAX_DIRECT_SOLVE_RANKS = 4 + + +def _estimate_dofs(mesh_stats: dict) -> int | None: + """Estimate total unknowns from mesh statistics. + + Uses the same element-count * polynomial-order heuristic as + :meth:`PalaceSimMixin.print_mesh_stats` (order 2 for 2D, 1 for 3D). + """ + if not mesh_stats: + return None + elements = mesh_stats.get("elements") or 0 + if not elements: + return None + is_3d = bool(mesh_stats.get("tetrahedra")) + p = 1 if is_3d else 2 + nd_dofs = elements * p * (p + 1) + h1_dofs = elements * (p + 1) * (p + 2) // 2 + return nd_dofs + h1_dofs + + +def _recommend_parallel( + mesh_stats: dict, + simulation_type: str, + num_processes: int | None, + num_threads: int | None, + physical_cpus: int | None = None, +) -> tuple[int, int | None]: + """Recommend MPI/OpenMP settings that avoid pathological direct solves. + + SuperLU_DIST factors the MPI rank count into a 2D processor grid. For + small problems (especially 2D mode analysis), using every physical core + produces a grid (e.g. 4x4 at 16 ranks) whose factorization is + communication-bound and effectively hangs. This returns a safe default: + + - 2D (``boundarymode`` or a planar mesh): a single MPI rank with OpenMP + threads. MPI for a 2D problem is overkill and the sparse-direct grid + does not scale. + - 3D: at most ``_MAX_DIRECT_SOLVE_RANKS`` ranks, and never more than + ``est_dofs // _MIN_DOFS_PER_RANK`` so each rank has enough unknowns. + + Explicit caller-supplied values are always respected; when they exceed + the recommendation the caller should log a warning. + + Returns: + ``(num_processes, num_threads)``. ``num_threads`` stays ``None`` + unless it should be auto-defaulted to the physical core count. + """ + if physical_cpus is None: + physical_cpus = _count_physical_cpus() + + if num_processes is not None: + # Explicit request: keep it, default threads only for serial runs. + if num_processes == 1 and num_threads is None: + return num_processes, physical_cpus + return num_processes, num_threads + + is_2d = simulation_type == "boundarymode" + if not is_2d and mesh_stats: + bbox = mesh_stats.get("bbox") or {} + dz = bbox.get("zmax", bbox.get("zmin", 0.0)) - bbox.get( + "zmin", bbox.get("zmax", 0.0) + ) + if not mesh_stats.get("tetrahedra") and dz <= 1e-6: + is_2d = True + + if is_2d: + return 1, (num_threads if num_threads is not None else physical_cpus) + + # 3D: size-aware cap for sparse-direct solves. + est_dofs = _estimate_dofs(mesh_stats) + size_cap = physical_cpus + if est_dofs is not None: + size_cap = max(1, est_dofs // _MIN_DOFS_PER_RANK) + recommended = min(physical_cpus, _MAX_DIRECT_SOLVE_RANKS, size_cap) + return max(1, recommended), num_threads + + class PalaceSimMixin: """Mixin providing common methods for all Palace simulation classes. @@ -1753,10 +1838,16 @@ def run_local( use_apptainer: If True (default), run via Apptainer using SIF file. If False, run Palace executable directly. Ignored when ``palace_executable`` is explicitly provided. - num_processes: Number of MPI processes. If None (default), - uses all available CPUs. - num_threads: Number of OpenMP threads to use for OpenMP builds, default is 1 - or the value of OMP_NUM_THREADS in the environment + num_processes: Number of MPI processes. If None (default), a + problem-size-aware default is chosen: 2D mode-analysis runs + use a single MPI rank (SuperLU_DIST's 2D processor grid does + not scale on small 2D problems and can effectively hang at + 8-16 ranks), while 3D runs are capped to a few ranks by the + estimated unknown count. An explicit value is always respected. + num_threads: Number of OpenMP threads to use for OpenMP builds. + When running with a single MPI rank and ``num_threads`` is + omitted, defaults to the number of physical CPU cores so + shared-memory parallelism is used. verbose: Print progress messages and stream Palace output in real time Returns: @@ -1806,9 +1897,48 @@ def run_local( config_path = output_dir / "config.json" mesh_path = output_dir / "palace.msh" - # Default to all available CPUs when caller does not specify -np. + # Choose safe parallel defaults. SuperLU_DIST's 2D processor grid does + # not scale on small problems (2D mode analysis at 8-16 ranks hangs), + # so 2D runs use a single MPI rank + OpenMP threads, and 3D direct + # solves are capped by problem size. + mesh_stats = {} + if self._last_mesh_result is not None: + mesh_stats = self._last_mesh_result.mesh_stats or {} + if num_processes is None: - num_processes = _count_physical_cpus() + num_processes, num_threads = _recommend_parallel( + mesh_stats, + self.simulation_type, + None, + num_threads, + ) + if verbose: + logger.info( + "Parallel config: %d MPI rank(s)%s", + num_processes, + f", {num_threads} OpenMP thread(s)" if num_threads else "", + ) + else: + # Explicit request: keep it, but warn when it exceeds what the + # sparse-direct solver can use efficiently on this problem. + rec_processes, _rec_threads = _recommend_parallel( + mesh_stats, + self.simulation_type, + None, + None, + ) + if num_processes > rec_processes: + logger.warning( + "Requested %d MPI ranks but SuperLU_DIST direct solve " + "does not scale beyond %d rank(s) on this problem size; " + "the factorization may be pathologically slow. Pass " + "num_processes=%d or use OpenMP threads instead.", + num_processes, + rec_processes, + rec_processes, + ) + if num_processes == 1 and num_threads is None: + num_threads = _count_physical_cpus() # Check required files exist if not config_path.exists(): diff --git a/src/gsim/palace/field_viz.py b/src/gsim/palace/field_viz.py index 78ed6de6..886c730e 100644 --- a/src/gsim/palace/field_viz.py +++ b/src/gsim/palace/field_viz.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, Sequence, cast +from typing import Any, Literal, cast import numpy as np import pyvista as pv +from pyvista.plotting._typing import ColormapOptions from gsim.palace.results import load_fields @@ -51,16 +53,13 @@ def resolve_physical_groups( requested = set(group_names) found: list[int] = [] missing = set(requested) - for name, (tag, dim) in m.field_data.items(): + for name, (tag, _dim) in m.field_data.items(): if name in requested: found.append(int(tag)) missing.discard(name) if missing: available = sorted(m.field_data.keys()) - msg = ( - f"Physical group(s) not found: {sorted(missing)}. " - f"Available: {available}" - ) + msg = f"Physical group(s) not found: {sorted(missing)}. Available: {available}" raise ValueError(msg) return found @@ -287,13 +286,14 @@ def plot_fields_2d( cycle: int | None = None, boundary: bool = False, physical_groups: Sequence[str] | None = None, - cmap: str = "hot", + cmap: ColormapOptions = "hot", clim: tuple[float, float] | None = None, title: str = "|E|", show_edges: bool = False, opacity: float = 1.0, show: bool = True, screenshot: str | Path | None = None, + mode: Literal["auto", "live", "static"] = "auto", ) -> Any: """Plot 2D field using PyVista, rendering directly on the mesh. @@ -321,18 +321,26 @@ def plot_fields_2d( show_edges: Toggle mesh edges on/off. opacity: Opacity of the mesh (0-1). show: If True, show the interactive PyVista window. + If a live view is already open in this process (or *mode* is + ``"static"``), the plot is rendered to a PNG and displayed inline + instead of opening a second live window. screenshot: If given, save a screenshot to this path. + mode: Rendering mode: ``"auto"`` (default), ``"live"`` or + ``"static"``. Overrides ``GSIM_VIZ_MODE`` for this call. Returns: The ``pv.Plotter`` instance. """ - from gsim.viz import _ensure_pyvista + from gsim.viz import _ensure_pyvista, _show_or_screenshot _ensure_pyvista() import pyvista as pv dataset = _source_to_dataset( - source, excitation=excitation, cycle=cycle, boundary=boundary, + source, + excitation=excitation, + cycle=cycle, + boundary=boundary, ) if physical_groups is not None: @@ -344,22 +352,32 @@ def plot_fields_2d( attribute_values = resolve_physical_groups(output_dir, physical_groups) if "attribute" not in dataset.cell_data: - msg = "Dataset has no cell_data['attribute'] — cannot filter by physical group" + msg = ( + "Dataset has no cell_data['attribute'] " + "— cannot filter by physical group" + ) raise ValueError(msg) attrs = np.asarray(dataset.cell_data["attribute"]) keep = np.where(np.isin(attrs, np.asarray(attribute_values)))[0] if keep.size == 0: - msg = f"No cells found for physical_groups={physical_groups} (attrs={attribute_values})" + msg = ( + f"No cells found for physical_groups={physical_groups} " + f"(attrs={attribute_values})" + ) raise ValueError(msg) logger.info( "Selected %d / %d cells for physical_groups=%s", - len(keep), dataset.n_cells, physical_groups, + len(keep), + dataset.n_cells, + physical_groups, ) dataset = dataset.extract_cells(keep) # Slice to the cross-section plane. - sliced, used_normal, axis_idx, axes = _slice_plane( - dataset, normal=normal, origin=origin, + sliced, _used_normal, _axis_idx, axes = _slice_plane( + dataset, + normal=normal, + origin=origin, ) if field not in sliced.point_data: @@ -384,10 +402,7 @@ def plot_fields_2d( finite = s[np.isfinite(s)] if finite.size > 0: vmax = float(np.percentile(finite, 98)) - if vmax > 0: - _clim = (0.0, vmax) - else: - _clim = None + _clim = (0.0, vmax) if vmax > 0 else None else: _clim = None @@ -407,19 +422,21 @@ def plot_fields_2d( pts = sliced.points h_vals = pts[:, axes[0]] v_vals = pts[:, axes[1]] - center = ((h_vals.min() + h_vals.max()) / 2, - (v_vals.min() + v_vals.max()) / 2) - span = (h_vals.max() - h_vals.min(), - v_vals.max() - v_vals.min()) + center = ((h_vals.min() + h_vals.max()) / 2, (v_vals.min() + v_vals.max()) / 2) + span = (h_vals.max() - h_vals.min(), v_vals.max() - v_vals.min()) dist = max(span) * 2.5 if max(span) > 0 else 1.0 pl.camera.focal_point = (center[0], center[1], 0.0) pl.camera.position = (center[0], center[1], dist) - if show and screenshot is None: - pl.show() if screenshot is not None: pl.screenshot(str(screenshot)) pl.close() + return pl + + if show: + _show_or_screenshot(pl, interactive=True, mode=mode) + else: + pl.close() return pl diff --git a/src/gsim/palace/mesh/generator.py b/src/gsim/palace/mesh/generator.py index 211ce671..46a9f9f2 100644 --- a/src/gsim/palace/mesh/generator.py +++ b/src/gsim/palace/mesh/generator.py @@ -1108,7 +1108,7 @@ def generate_mesh( refinement_lines, aggressive_size, max_mesh_size, - sampling=200, + sampling=400, dist_max=max_mesh_size * 0.5, ) gmsh_utils.finalize_mesh_fields([field_id]) diff --git a/src/gsim/viz.py b/src/gsim/viz.py index e65529b1..41e95665 100644 --- a/src/gsim/viz.py +++ b/src/gsim/viz.py @@ -9,6 +9,7 @@ import hashlib import logging +import os from pathlib import Path from typing import Any, Literal, cast @@ -17,25 +18,65 @@ logger = logging.getLogger(__name__) +#: Rendering modes for ``plot_mesh`` / ``plot_fields_2d``. +RenderMode = Literal["auto", "live", "static"] + +#: Global rendering mode, overridable per call. ``auto`` keeps the current +#: live PyVista view interactive and renders any additional views in the same +#: process as static inline images (avoids concurrent live trame servers). +_VIZ_MODE = os.environ.get("GSIM_VIZ_MODE", "auto").strip().lower() or "auto" + # -- Headless-safe PyVista initialisation ------------------------------------ +def _is_headless() -> bool: + """Return ``True`` when no usable X display is available. + + An explicit ``GSIM_FORCE_OFFSCREEN=1`` always forces off-screen + rendering, which is useful in CI where a display may be present but X + forwarding is undesirable. + """ + if os.environ.get("GSIM_FORCE_OFFSCREEN") == "1": + return True + return not os.environ.get("DISPLAY") + + +def _start_xvfb() -> None: + """Start a virtual frame buffer when ``pyvirtualdisplay`` is available.""" + try: + from pyvirtualdisplay import Display # pyright: ignore[reportMissingImports] + + display = Display(visible=False, size=(1440, 900)) + display.start() + except Exception: # optional dependency / headless host + logger.warning("pyvirtualdisplay not available; continuing without Xvfb") + + def _ensure_pyvista(): - """Import PyVista with off-screen rendering (headless-safe).""" - import os - import warnings + """Import PyVista, forcing off-screen rendering only when headless. - os.environ.pop("DISPLAY", None) - os.environ.setdefault("VTK_DEFAULT_RENDER_WINDOW_OFFSCREEN", "1") + When a usable ``DISPLAY`` is present normal rendering is left untouched + so interactive windows work. In headless contexts PyVista runs + off-screen (optionally backed by a virtual frame buffer), which is what + tests and CI rely on. ``GSIM_FORCE_OFFSCREEN=1`` always forces + off-screen rendering. + """ + headless = _is_headless() or _VIZ_MODE == "static" + if headless: + os.environ.pop("DISPLAY", None) + os.environ.setdefault("VTK_DEFAULT_RENDER_WINDOW_OFFSCREEN", "1") + + import warnings import pyvista as pv - pv.OFF_SCREEN = True - try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - pv.start_xvfb() - except Exception: - pass + if headless: + pv.OFF_SCREEN = True + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + _start_xvfb() + except Exception: + pass return pv @@ -54,6 +95,7 @@ def plot_mesh( interactive: bool = True, style: Literal["wireframe", "solid"] = "wireframe", transparent_groups: list[str] | None = None, + mode: RenderMode = "auto", ) -> None: """Plot a ``.msh`` mesh using PyVista. @@ -67,14 +109,17 @@ def plot_mesh( Args: msh_path: Path to ``.msh`` file. - output: Output PNG path (only used when ``interactive=False``). + output: Output PNG path (only used when rendering statically). show_groups: Group-name patterns to display (``None`` -> all). Example: ``["metal", "P"]`` to show metal layers and ports. - interactive: If ``True``, open an interactive 3-D viewer. - If ``False``, save a static PNG to *output*. + interactive: If ``True``, request an interactive 3-D view. If a live + view is already open in this process (or *mode* is ``"static"``) + the plot is rendered to a PNG instead. style: ``"wireframe"`` or ``"solid"``. transparent_groups: Group names rendered at low opacity in *solid* mode. Ignored in *wireframe* mode. + mode: Rendering mode: ``"auto"`` (default), ``"live"`` or ``"static"``. + Overrides ``GSIM_VIZ_MODE`` for this call. Example: >>> pa.plot_mesh("./sim/palace.msh", show_groups=["metal", "P"]) @@ -90,6 +135,7 @@ def plot_mesh( output=output, interactive=interactive, transparent_groups=transparent_groups or [], + mode=mode, ) else: _plot_wireframe( @@ -97,6 +143,7 @@ def plot_mesh( output=output, show_groups=show_groups, interactive=interactive, + mode=mode, ) @@ -111,13 +158,14 @@ def _plot_wireframe( output: str | Path | None, show_groups: list[str] | None, interactive: bool, + mode: RenderMode = "auto", ) -> None: """Wireframe renderer — one colour per matched group.""" mio = meshio.read(msh_path) group_map: dict[int, str] = {tag: name for name, (tag, _) in mio.field_data.items()} mesh = cast(pv.DataSet, pv.read(msh_path)) # ty: ignore[redundant-cast] - plotter = cast(Any, _make_plotter(interactive)) # ty: ignore[redundant-cast] + plotter = cast(Any, _make_plotter(interactive, mode=mode)) # ty: ignore[redundant-cast] if show_groups: ids = [ @@ -141,7 +189,7 @@ def _plot_wireframe( else: plotter.add_mesh(mesh, style="wireframe", color="black", line_width=1) - _finish(plotter, msh_path, output=output, interactive=interactive) + _finish(plotter, msh_path, output=output, interactive=interactive, mode=mode) # --------------------------------------------------------------------------- @@ -229,6 +277,7 @@ def _plot_solid( output: str | Path | None, interactive: bool, transparent_groups: list[str], + mode: RenderMode = "auto", ) -> None: """Solid renderer — coloured surfaces per physical group.""" mio = meshio.read(msh_path) @@ -305,7 +354,7 @@ def _plot_solid( transparent_mask = np.isin(plain_names, transparent_groups) opaque_mask = ~transparent_mask - plotter = cast(Any, _make_plotter(interactive)) # ty: ignore[redundant-cast] + plotter = cast(Any, _make_plotter(interactive, mode=mode)) # ty: ignore[redundant-cast] # Opaque surfaces with categorical colour map ------------------------- if np.any(opaque_mask): @@ -354,7 +403,7 @@ def _plot_solid( if transparent_legend_entries: plotter.add_legend(transparent_legend_entries, bcolor="white", border=True) - _finish(plotter, msh_path, output=output, interactive=interactive) + _finish(plotter, msh_path, output=output, interactive=interactive, mode=mode) # --------------------------------------------------------------------------- @@ -362,40 +411,121 @@ def _plot_solid( # --------------------------------------------------------------------------- -def _make_plotter(interactive: bool) -> Any: +# Tracks whether a live (interactive) PyVista view is open. Once a view has +# been shown live, subsequent interactive plots render statically unless +# explicitly forced with ``mode="live"`` (or ``GSIM_VIZ_MODE=live``). +_LIVE_PLOTTER_OPEN = False + + +def _mode_for(mode: RenderMode) -> RenderMode: + """Resolve the effective rendering mode for a plot. + + ``auto`` preserves the interactive PyVista window for the first view; + additional views in the same process fall back to static rendering via + the live-view guard in :func:`_show_or_screenshot`. ``GSIM_VIZ_MODE`` + can force ``"live"`` or ``"static"`` globally. + """ + if mode != "auto": + return mode + return "live" if _VIZ_MODE not in ("static", "none", "off") else "static" + + +def _make_plotter(interactive: bool, *, mode: RenderMode = "auto") -> Any: """Create a PyVista plotter with standard window settings.""" pv = _ensure_pyvista() - if interactive: - plotter = pv.Plotter(window_size=[1200, 900]) + off_screen = ( + not interactive + or _mode_for(mode) == "static" + or (_LIVE_PLOTTER_OPEN and mode != "live") + ) + density = {"window_size": [1200, 900]} + if off_screen: + plotter = pv.Plotter(off_screen=True, **density) else: - plotter = pv.Plotter(off_screen=True, window_size=[1200, 900]) + plotter = pv.Plotter(**density) plotter.set_background("white") return plotter -def _finish( +def _show_or_screenshot( plotter: Any, - msh_path: Path, *, - output: str | Path | None, + msh_path: Path | None = None, + output: str | Path | None = None, interactive: bool, + mode: RenderMode = "auto", + suffix: str = ".png", ) -> None: - """Show or screenshot the plotter and clean up.""" + """Show a plotter live, or screenshot it, and clean up. + + Only one live PyVista view is kept open per process. When *interactive* + is set but a live view is already open (common in notebooks that render + several plots in one kernel), the plot is rendered to a temporary PNG and + displayed inline instead of spawning a second concurrent live trame + server, which otherwise causes blank windows / cache ``KeyError`` errors. + Passing ``mode="live"`` lets a caller force a second live view; passing + ``mode="static"`` (or setting ``GSIM_VIZ_MODE=static``) renders statically. + """ + global _LIVE_PLOTTER_OPEN # noqa: PLW0603 + plotter.camera_position = "iso" plotter.show_axes() - if interactive: + + resolved = _mode_for(mode) + if ( + interactive + and resolved == "live" + and (mode == "live" or not _LIVE_PLOTTER_OPEN) + ): + _LIVE_PLOTTER_OPEN = True plotter.show() - else: - if output is None: - output = msh_path.with_suffix(".png") - plotter.screenshot(str(output)) - plotter.close() - try: - from IPython.display import Image, display + return + + # Static screenshot: either interactive=False, forced static, or a live + # view is already open elsewhere in this process. + dest = output if output is not None else _default_output(msh_path, suffix) + plotter.screenshot(str(dest)) + plotter.close() + _disp_img(Path(dest)) + + +def _default_output(msh_path: Path | None, suffix: str) -> Path: + """Return the default screenshot path for a plot. + + Uses the mesh path with a new suffix, or a fresh temporary file when no + mesh path is available. + """ + if msh_path is not None: + return msh_path.with_suffix(suffix) + import tempfile + + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + name = tmp.name + return Path(name) - display(Image(str(output))) - except ImportError: - logger.info("Saved mesh plot to %s", output) + +def _disp_img(path: Path) -> None: + """Display a saved image inline when running inside IPython.""" + try: + from IPython.display import Image, display + + display(Image(str(path))) + except ImportError: + logger.info("Saved plot to %s", path) + + +def _finish( + plotter: Any, + msh_path: Path, + *, + output: str | Path | None = None, + interactive: bool, + mode: RenderMode = "auto", +) -> None: + """Show or screenshot the plotter and clean up.""" + _show_or_screenshot( + plotter, msh_path=msh_path, output=output, interactive=interactive, mode=mode + ) def _color_for_group(name: str) -> str: diff --git a/tests/common/conftest.py b/tests/common/conftest.py index ff74e4a6..e523882a 100644 --- a/tests/common/conftest.py +++ b/tests/common/conftest.py @@ -1,7 +1,19 @@ -"""Activate the generic PDK for tests in gsim.common that build components.""" +"""Activate the generic PDK for tests in gsim.common that build components. + +Activation happens before every test (autouse fixture) rather than only at +collection time: Palace tests (e.g. the IHP mesh-regression suite) switch the +active PDK during execution and never restore it, so module-level activation +alone lets the IHP PDK leak into later ``gsim.common`` tests under the random +test ordering used by ``pytest-randomly``. +""" from __future__ import annotations import gdsfactory as gf +import pytest + -gf.gpdk.PDK.activate() +@pytest.fixture(autouse=True) +def _generic_pdk_active() -> None: + """Ensure the generic PDK is active for each test.""" + gf.gpdk.PDK.activate() diff --git a/tests/common/test_cross_section.py b/tests/common/test_cross_section.py index 8b8fd80d..bee8bda4 100644 --- a/tests/common/test_cross_section.py +++ b/tests/common/test_cross_section.py @@ -1,10 +1,11 @@ -"""Tests for gsim.common.cross_section.""" +"""Tests for gsim.common.cross_section and doping-profile helpers.""" from __future__ import annotations from dataclasses import FrozenInstanceError from typing import Literal +import gdsfactory as gf import pytest from gsim.common.cross_section import ( @@ -412,3 +413,119 @@ def test_no_doping_no_rib_layers(self): assert "p_rib" not in stack.layers assert "pp_slab_0" not in stack.layers assert "core" in stack.layers + + +@pytest.fixture +def doping_sides(): + return { + "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, + "lower": {"base_layer": (24, 0), "name_prefix": "npp_slab_", "sign": -1}, + } + + +def _doping_profile(): + return { + "upper": [(2.0, 2e4), (2.0, 8e4)], + "lower": [(2.0, 2e4), (2.0, 8e4)], + } + + +def test_make_doping_profile_basic(doping_sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=-20.0, + rib_width=0.4, + profile=_doping_profile(), + sides=doping_sides, + zmin=0.0, + zmax=0.09, + ) + + expected = {"pp_slab_0", "pp_slab_1", "npp_slab_0", "npp_slab_1"} + assert set(result["layer_specs"]) == expected + assert set(result["materials"]) == set(result["layer_specs"]) + + upper = result["centres"]["upper"] + lower = result["centres"]["lower"] + rib_upper_edge = -20.0 + 0.4 / 2 + rib_lower_edge = -20.0 - 0.4 / 2 + assert upper[0] == pytest.approx(rib_upper_edge + 2.0 / 2) + assert upper[1] == pytest.approx(rib_upper_edge + 2.0 + 2.0 / 2) + assert lower[0] == pytest.approx(rib_lower_edge - 2.0 / 2) + assert lower[1] == pytest.approx(rib_lower_edge - 2.0 - 2.0 / 2) + + +def test_make_doping_profile_layer_spec(doping_sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_doping_profile(), + sides=doping_sides, + zmin=0.0, + zmax=0.09, + ) + + layer = result["layer_specs"]["pp_slab_0"] + assert layer.gds_layer == (23, 0) + assert layer.zmin == 0.0 + assert layer.zmax == 0.09 + assert layer.thickness == 0.09 + assert layer.material == "pp_slab_0" + + assert result["layer_specs"]["pp_slab_1"].gds_layer == (23, 1) + assert result["layer_specs"]["npp_slab_0"].gds_layer == (24, 0) + + +def test_make_doping_profile_materials(doping_sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_doping_profile(), + sides=doping_sides, + zmin=0.0, + zmax=0.09, + ) + assert result["materials"]["pp_slab_0"].conductivity == 2e4 + assert result["materials"]["pp_slab_1"].conductivity == 8e4 + assert result["materials"]["npp_slab_1"].conductivity == 8e4 + + +def test_make_doping_profile_empty_side(doping_sides): + comp = gf.Component() + result = make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile={"upper": [], "lower": [(1.0, 1e4)]}, + sides=doping_sides, + zmin=0.0, + zmax=0.09, + ) + assert "pp_slab_0" not in result["layer_specs"] + assert "npp_slab_0" in result["layer_specs"] + assert result["centres"]["upper"] == [] + + +def test_make_doping_profile_geometry_added(doping_sides): + comp = gf.Component() + make_doping_profile( + comp, + length=10.0, + rib_center_y=0.0, + rib_width=1.0, + profile=_doping_profile(), + sides=doping_sides, + zmin=0.0, + zmax=0.09, + ) + total = len(comp.get_polygons()) + assert total == 4 diff --git a/tests/common/test_doping_profile.py b/tests/common/test_doping_profile.py deleted file mode 100644 index 2e8b86dc..00000000 --- a/tests/common/test_doping_profile.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for the generic doping-profile helper (gsim.common.stack.doping).""" - -from __future__ import annotations - -import gdsfactory as gf -import pytest - -from gsim.common.stack.doping import make_doping_profile - - -@pytest.fixture -def sides(): - return { - "upper": {"base_layer": (23, 0), "name_prefix": "pp_slab_", "sign": 1}, - "lower": {"base_layer": (24, 0), "name_prefix": "npp_slab_", "sign": -1}, - } - - -def _profile(): - return { - "upper": [(2.0, 2e4), (2.0, 8e4)], - "lower": [(2.0, 2e4), (2.0, 8e4)], - } - - -def test_make_doping_profile_basic(sides): - comp = gf.Component() - result = make_doping_profile( - comp, - length=10.0, - rib_center_y=-20.0, - rib_width=0.4, - profile=_profile(), - sides=sides, - zmin=0.0, - zmax=0.09, - ) - - expected = {"pp_slab_0", "pp_slab_1", "npp_slab_0", "npp_slab_1"} - assert set(result["layer_specs"]) == expected - assert set(result["materials"]) == set(result["layer_specs"]) - - # Centres: regions start at the rib edges and are contiguous. - upper = result["centres"]["upper"] - lower = result["centres"]["lower"] - rib_upper_edge = -20.0 + 0.4 / 2 - rib_lower_edge = -20.0 - 0.4 / 2 - assert upper[0] == pytest.approx(rib_upper_edge + 2.0 / 2) - assert upper[1] == pytest.approx(rib_upper_edge + 2.0 + 2.0 / 2) - assert lower[0] == pytest.approx(rib_lower_edge - 2.0 / 2) - assert lower[1] == pytest.approx(rib_lower_edge - 2.0 - 2.0 / 2) - - -def test_make_doping_profile_layer_spec(sides): - comp = gf.Component() - result = make_doping_profile( - comp, - length=10.0, - rib_center_y=0.0, - rib_width=1.0, - profile=_profile(), - sides=sides, - zmin=0.0, - zmax=0.09, - ) - - layer = result["layer_specs"]["pp_slab_0"] - assert layer.gds_layer == (23, 0) - assert layer.zmin == 0.0 - assert layer.zmax == 0.09 - assert layer.thickness == 0.09 - assert layer.material == "pp_slab_0" - - # Second region uses an offset datatype within the base layer. - assert result["layer_specs"]["pp_slab_1"].gds_layer == (23, 1) - assert result["layer_specs"]["npp_slab_0"].gds_layer == (24, 0) - - -def test_make_doping_profile_materials(sides): - comp = gf.Component() - result = make_doping_profile( - comp, - length=10.0, - rib_center_y=0.0, - rib_width=1.0, - profile=_profile(), - sides=sides, - zmin=0.0, - zmax=0.09, - ) - assert result["materials"]["pp_slab_0"].conductivity == 2e4 - assert result["materials"]["pp_slab_1"].conductivity == 8e4 - assert result["materials"]["npp_slab_1"].conductivity == 8e4 - - -def test_make_doping_profile_empty_side(sides): - comp = gf.Component() - result = make_doping_profile( - comp, - length=10.0, - rib_center_y=0.0, - rib_width=1.0, - profile={"upper": [], "lower": [(1.0, 1e4)]}, - sides=sides, - zmin=0.0, - zmax=0.09, - ) - assert "pp_slab_0" not in result["layer_specs"] - assert "npp_slab_0" in result["layer_specs"] - assert result["centres"]["upper"] == [] - - -def test_make_doping_profile_geometry_added(sides): - comp = gf.Component() - make_doping_profile( - comp, - length=10.0, - rib_center_y=0.0, - rib_width=1.0, - profile=_profile(), - sides=sides, - zmin=0.0, - zmax=0.09, - ) - # 4 rectangles drawn (2 upper + 2 lower). - total = len(comp.get_polygons()) - assert total == 4 diff --git a/tests/palace/test_field_viz.py b/tests/palace/test_field_viz.py index e138ca3b..50dc08c9 100644 --- a/tests/palace/test_field_viz.py +++ b/tests/palace/test_field_viz.py @@ -31,18 +31,16 @@ def planar_vector_dataset() -> pv.DataSet: def test_plot_fields_2d_dataset_smoke(planar_vector_dataset: pv.DataSet) -> None: - """Matplotlib plotting utility should render from a dataset source.""" - fig, ax, out = field_viz.plot_fields_2d( + """PyVista plotting utility should render from a dataset source.""" + plotter = field_viz.plot_fields_2d( planar_vector_dataset, normal="z", origin=0.0, show=False, ) - assert fig is not None - assert ax is not None - assert out.u.shape == out.v.shape - fig.clf() + assert isinstance(plotter, pv.Plotter) + plotter.close() def test_plot_fields_2d_loads_from_source( @@ -67,7 +65,7 @@ def _fake_load_fields( monkeypatch.setattr(field_viz, "load_fields", _fake_load_fields) - fig, _, _ = field_viz.plot_fields_2d( + plotter = field_viz.plot_fields_2d( "dummy-output-dir", excitation=3, cycle=7, @@ -83,7 +81,7 @@ def _fake_load_fields( "cycle": 7, "boundary": True, } - fig.clf() + plotter.close() def test_plot_fields_2d_missing_field_raises(planar_vector_dataset: pv.DataSet) -> None: diff --git a/tests/palace/test_run_local.py b/tests/palace/test_run_local.py index 64a47630..72ddf7f4 100644 --- a/tests/palace/test_run_local.py +++ b/tests/palace/test_run_local.py @@ -6,7 +6,167 @@ from types import SimpleNamespace from typing import cast -from gsim.palace import DrivenSim +from gsim.palace import BoundaryModeSim, DrivenSim +from gsim.palace.base import _recommend_parallel + + +def _mesh_result(elements: int, tetrahedra: int = 0, bbox: dict | None = None): + """A minimal MeshResult-like object exposing mesh_stats.""" + stats: dict = { + "elements": elements, + "nodes": elements, + } + if tetrahedra: + stats["tetrahedra"] = tetrahedra + if bbox is not None: + stats["bbox"] = bbox + return SimpleNamespace(mesh_stats=stats, groups={}) + + +def _setup_sim(sim, output_dir: Path) -> None: + postpro_dir = output_dir / "output" / "palace" + output_dir.mkdir(parents=True) + postpro_dir.mkdir(parents=True) + (output_dir / "config.json").write_text("{}") + (output_dir / "palace.msh").write_text("mesh") + sim.set_output_dir(output_dir) + + +def _setup_local_palace(monkeypatch, tmp_path: Path) -> None: + """Provide a fake ./bin/palace and resolve it from cwd.""" + local_bin_dir = tmp_path / "bin" + local_bin_dir.mkdir() + local_palace = local_bin_dir / "palace" + local_palace.write_text("#!/bin/sh\nexit 0\n") + local_palace.chmod(0o755) + monkeypatch.chdir(tmp_path) + + +def test_recommend_parallel_2d_uses_single_rank_openmp(monkeypatch): + """2D mode analysis defaults to 1 MPI rank + OpenMP threads.""" + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 16) + procs, threads = _recommend_parallel( + {"elements": 50_000}, "boundarymode", None, None + ) + assert procs == 1 + assert threads == 16 + + +def test_recommend_parallel_3d_capped_by_dofs(monkeypatch): + """3D runs are capped by problem size and never exceed 4 ranks.""" + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 16) + # ~2.5M DOFs, enough for several ranks, but still capped at 4. + procs, threads = _recommend_parallel( + {"elements": 500_000, "tetrahedra": 500_000}, "driven", None, None + ) + assert procs == 4 + assert threads is None + + # Tiny 3D problem: only 1 rank. + procs, _ = _recommend_parallel( + {"elements": 1_000, "tetrahedra": 1_000}, "driven", None, None + ) + assert procs == 1 + + +def test_recommend_parallel_respects_explicit_processes(monkeypatch): + """An explicit num_processes is kept unchanged.""" + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 16) + procs, threads = _recommend_parallel({"elements": 50_000}, "boundarymode", 2, None) + assert procs == 2 + assert threads is None + + procs, threads = _recommend_parallel({"elements": 50_000}, "boundarymode", 1, None) + assert procs == 1 + assert threads == 16 # serial run defaults threads to cores + + +def test_run_local_boundarymode_defaults_to_single_rank(monkeypatch, tmp_path): + """BoundaryModeSim.run_local() without args uses -np 1 and -nt .""" + _setup_local_palace(monkeypatch, tmp_path) + output_dir = tmp_path / "sim" + + monkeypatch.delenv("PALACE_SIF", raising=False) + monkeypatch.delenv("PALACE_EXECUTABLE", raising=False) + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 8) + + captured: dict[str, object] = {} + + def _fake_run(cmd, **_kwargs): + captured["cmd"] = cmd + return SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", _fake_run) + + sim = BoundaryModeSim() + sim._last_mesh_result = _mesh_result(50_000) + _setup_sim(sim, output_dir) + + result = sim.run_local(verbose=False) + + assert isinstance(result, dict) + cmd = cast(list[str], captured["cmd"]) + assert "-np" in cmd + assert "1" in cmd[cmd.index("-np") + 1 :][:1] + assert "-nt" in cmd + assert "8" in cmd[cmd.index("-nt") + 1 :][:1] + + +def test_run_local_explicit_large_processes_warns(monkeypatch, tmp_path, caplog): + """Requesting too many ranks for a 2D problem logs a warning.""" + _setup_local_palace(monkeypatch, tmp_path) + output_dir = tmp_path / "sim" + monkeypatch.delenv("PALACE_SIF", raising=False) + monkeypatch.delenv("PALACE_EXECUTABLE", raising=False) + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 16) + + captured: dict[str, object] = {} + + def _fake_run(cmd, **_kwargs): + captured["cmd"] = cmd + return SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", _fake_run) + + sim = BoundaryModeSim() + sim._last_mesh_result = _mesh_result(50_000) + _setup_sim(sim, output_dir) + + with caplog.at_level("WARNING", logger="gsim.palace.base"): + sim.run_local(num_processes=8, verbose=False) + + assert any("does not scale beyond 1 rank" in r.message for r in caplog.records) + # The explicit request is still respected. + cmd = cast(list[str], captured["cmd"]) + assert "8" in cmd[cmd.index("-np") + 1 :][:1] + + +def test_run_local_3d_caps_default_processes(monkeypatch, tmp_path): + """A 3D DrivenSim default is capped to 4 ranks regardless of cores.""" + _setup_local_palace(monkeypatch, tmp_path) + output_dir = tmp_path / "sim" + monkeypatch.delenv("PALACE_SIF", raising=False) + monkeypatch.delenv("PALACE_EXECUTABLE", raising=False) + monkeypatch.setattr("gsim.palace.base._count_physical_cpus", lambda: 32) + + captured: dict[str, object] = {} + + def _fake_run(cmd, **_kwargs): + captured["cmd"] = cmd + return SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", _fake_run) + + sim = DrivenSim() + sim._last_mesh_result = _mesh_result(1_000_000, tetrahedra=1_000_000) + _setup_sim(sim, output_dir) + + result = sim.run_local(verbose=False) + + assert isinstance(result, dict) + cmd = cast(list[str], captured["cmd"]) + assert "-np" in cmd + assert "4" in cmd[cmd.index("-np") + 1 :][:1] def test_run_local_accepts_relative_local_executable(monkeypatch, tmp_path): @@ -168,7 +328,7 @@ def _fake_run(cmd, **kwargs): sim = DrivenSim() sim.set_output_dir(output_dir) - result = sim.run_local(num_processes=1, verbose=False) + result = sim.run_local(num_processes=1, verbose=False, use_apptainer=True) assert isinstance(result, dict) cmd = cast(list[str], captured["cmd"]) diff --git a/tests/test_viz_mesh.py b/tests/test_viz_mesh.py index fdeded72..7573f2f3 100644 --- a/tests/test_viz_mesh.py +++ b/tests/test_viz_mesh.py @@ -209,3 +209,79 @@ def test_plot_solid_skips_unsupported_blocks(tmp_path: Path) -> None: viz.plot_mesh(msh, output=out, interactive=False, style="solid") assert out.exists() + + +class _FakePlotter: + """Minimal stand-in for a PyVista plotter in mode-resolution tests.""" + + camera_position = None + + def __init__(self) -> None: + self.shown = 0 + self.screenshotted = 0 + self.closed = 0 + + def show_axes(self) -> None: + return None + + def show(self) -> None: + self.shown += 1 + + def screenshot(self, _path: object) -> None: + self.screenshotted += 1 + + def close(self) -> None: + self.closed += 1 + + +def test_show_or_screenshot_first_live_then_static( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode keeps the first interactive view, renders later ones as static PNGs.""" + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + + first = _FakePlotter() + viz._show_or_screenshot(first, interactive=True, mode="auto") + assert first.shown == 1 + assert first.screenshotted == 0 + assert viz._LIVE_PLOTTER_OPEN + + second = _FakePlotter() + viz._show_or_screenshot(second, interactive=True, mode="auto") + assert second.shown == 0 + assert second.screenshotted == 1 + assert second.closed == 1 + + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + + +def test_mode_live_forces_show(monkeypatch: pytest.MonkeyPatch) -> None: + """mode='live' forces a live view even when one is already open.""" + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", True) + + forced = _FakePlotter() + viz._show_or_screenshot(forced, interactive=True, mode="live") + assert forced.shown == 1 + + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + + +def test_mode_static_always_screenshots(monkeypatch: pytest.MonkeyPatch) -> None: + """mode='static' renders a screenshot even before any live view.""" + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + + static = _FakePlotter() + viz._show_or_screenshot(static, interactive=True, mode="static") + assert static.shown == 0 + assert static.screenshotted == 1 + + monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + + +def test_mode_for_respects_global_static(monkeypatch: pytest.MonkeyPatch) -> None: + """GSIM_VIZ_MODE=static makes 'auto' resolve to static.""" + monkeypatch.setattr(viz, "_VIZ_MODE", "static") + assert viz._mode_for("auto") == "static" + assert viz._mode_for("live") == "live" + monkeypatch.setattr(viz, "_VIZ_MODE", "auto") + assert viz._mode_for("auto") == "live" From 9e1c9e4a9b76a468d9202169540b084cb7161e66 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Fri, 7 Aug 2026 03:52:23 -0300 Subject: [PATCH 5/6] feat: interactive 2D/mesh views on a shared trame server with per-call override - interactive param is tri-state (None/True/False): explicit True forces a live view, False forces static, None follows set_interactive_mode (off by default) - notebook live views render as one widget per plot on the single shared trame server (server-side by default; GSIM_TRAME_BACKEND / set_trame_backend to switch to client-side vtk.js) - keep live plotters in a registry (interactive_views/close_interactive_views/ close_interactive_view) so multiple views coexist without concurrent servers - front-facing camera for plot_mesh on planar meshes; plot_fields_2d camera preserved through the interactive path - fall back to a static image if a trame widget cannot be created --- nbs/palace_2d_twmzm.ipynb | 102 +++++---- nbs/palace_2d_twmzm.py | 38 ++-- src/gsim/palace/__init__.py | 15 +- src/gsim/palace/base.py | 15 +- src/gsim/palace/field_viz.py | 33 +-- src/gsim/viz.py | 368 ++++++++++++++++++++++++++++----- tests/palace/test_field_viz.py | 27 +++ tests/test_viz_mesh.py | 282 +++++++++++++++++++++++-- 8 files changed, 733 insertions(+), 147 deletions(-) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index 309166cf..b25e179b 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "intro", + "id": "0", "metadata": {}, "source": [ "# Palace 2D Mode Analysis: Travelling-Wave Mach-Zehnder Modulator\n", @@ -27,7 +27,7 @@ }, { "cell_type": "markdown", - "id": "geometry-params-header", + "id": "1", "metadata": {}, "source": [ "## Geometry & materials parameters\n", @@ -51,7 +51,7 @@ { "cell_type": "code", "execution_count": null, - "id": "geometry-params", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -102,7 +102,7 @@ }, { "cell_type": "markdown", - "id": "build-geometry-header", + "id": "3", "metadata": {}, "source": [ "## Build TW-MZM cross-section geometry\n", @@ -114,7 +114,7 @@ { "cell_type": "code", "execution_count": null, - "id": "build-geometry", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -192,7 +192,7 @@ }, { "cell_type": "markdown", - "id": "section-header", + "id": "5", "metadata": {}, "source": [ "## Inspect 2D cross-section\n", @@ -205,7 +205,7 @@ { "cell_type": "code", "execution_count": null, - "id": "stack-section", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -235,7 +235,7 @@ }, { "cell_type": "markdown", - "id": "plot-header", + "id": "7", "metadata": {}, "source": [ "## Plot the 2D cross-section\n", @@ -248,7 +248,7 @@ { "cell_type": "code", "execution_count": null, - "id": "plot-params", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -276,7 +276,7 @@ { "cell_type": "code", "execution_count": null, - "id": "zoom-plot", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -297,7 +297,7 @@ }, { "cell_type": "markdown", - "id": "rf-params-header", + "id": "10", "metadata": {}, "source": [ "## RF simulation (50 GHz)\n", @@ -309,7 +309,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-params", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -352,7 +352,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-setup", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -387,7 +387,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-mesh-plot", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -402,7 +402,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-config", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -415,14 +415,11 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-run", + "id": "15", "metadata": {}, "outputs": [], "source": [ "# -- RF simulation (50 GHz) ------------------------------------------------\n", - "# 2D mode analysis defaults to a single MPI rank + OpenMP threads: Palace's\n", - "# SuperLU_DIST direct solve does not scale on small 2D problems (16 MPI\n", - "# ranks can effectively hang), so MPI is unnecessary here.\n", "results = sim.run_local(verbose=True)\n", "results.print()" ] @@ -430,7 +427,7 @@ { "cell_type": "code", "execution_count": null, - "id": "rf-post", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -453,13 +450,14 @@ " RF_OUTPUT_DIR,\n", " field=RF_FIELD,\n", " title=RF_FIELD_TITLE,\n", + " interactive=True,\n", ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "rf-postproc-rib", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -471,12 +469,13 @@ " field=RF_FIELD,\n", " physical_groups=RIB_PHYSICAL_GROUPS,\n", " title=\"RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)\",\n", + " interactive=True,\n", ")" ] }, { "cell_type": "markdown", - "id": "opt-params-header", + "id": "18", "metadata": {}, "source": [ "## Optical simulation (1550 nm)\n", @@ -490,7 +489,7 @@ { "cell_type": "code", "execution_count": null, - "id": "opt-params", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -506,7 +505,7 @@ "OPT_OUTPUT_DIR = \"./palace-sim-mzm-pn-opt\"\n", "OPT_MESH = {\n", " \"preset\": \"default\",\n", - " \"refined_mesh_size\": 0.02,\n", + " \"refined_mesh_size\": 0.2,\n", " \"max_mesh_size\": 0.5,\n", "}" ] @@ -514,19 +513,19 @@ { "cell_type": "code", "execution_count": null, - "id": "optical-run", + "id": "20", "metadata": {}, "outputs": [], "source": [ "# -- Optical setup + run (1550 nm) ---------------------------------------\n", - "sim_opt = BoundaryModeSim()\n", - "sim_opt.set_output_dir(OPT_OUTPUT_DIR)\n", - "sim_opt.set_stack(stack)\n", - "sim_opt.set_airbox(**AIRBOX)\n", - "sim_opt.set_geometry(comp)\n", - "\n", - "sim_opt.set_cross_section(f\"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}\")\n", - "sim_opt.set_boundary_mode(\n", + "sim_optical = BoundaryModeSim()\n", + "sim_optical.set_output_dir(OPT_OUTPUT_DIR)\n", + "sim_optical.set_stack(stack)\n", + "sim_optical.set_airbox(**AIRBOX)\n", + "sim_optical.set_geometry(comp)\n", + "\n", + "sim_optical.set_cross_section(f\"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}\")\n", + "sim_optical.set_boundary_mode(\n", " freq=F_OPT,\n", " num_modes=NUM_OPT_MODES,\n", " save=2,\n", @@ -534,21 +533,44 @@ " tolerance=TOLERANCE,\n", ")\n", "\n", - "sim_opt.mesh(\n", + "sim_optical.mesh(\n", " preset=OPT_MESH[\"preset\"],\n", " refined_mesh_size=OPT_MESH[\"refined_mesh_size\"],\n", " max_mesh_size=OPT_MESH[\"max_mesh_size\"],\n", " margin_x=MESH_MARGIN_X,\n", " margin_y=MESH_MARGIN_Y,\n", - ")\n", - "\n", - "opt_results = sim_opt.run_local(verbose=True)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# Interactive 3D mesh visualisation\n", + "sim_optical.plot_mesh(\n", + " transparent_groups=[\"air__None\", \"air__passive\", \"oxide__passive\"],\n", + " style=\"solid\",\n", + " interactive=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "opt_results = sim_optical.run_local(verbose=True)\n", "opt_results.print()" ] }, { "cell_type": "markdown", - "id": "summary", + "id": "23", "metadata": {}, "source": [ "## Summary\n", @@ -584,14 +606,14 @@ "2. Run `sim.run_local(verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). 2D\n", " mode analysis defaults to a single MPI rank + OpenMP threads; pass `num_processes=1` explicitly if you want\n", " to be explicit about it.\n", - "3. Run `sim_opt.run_local(verbose=True)` for the optical mode.\n", + "3. Run `sim_optical.run_local(verbose=True)` for the optical mode.\n", "4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "final-dump", + "id": "24", "metadata": {}, "outputs": [], "source": [ diff --git a/nbs/palace_2d_twmzm.py b/nbs/palace_2d_twmzm.py index ee4bd327..f481dc0a 100644 --- a/nbs/palace_2d_twmzm.py +++ b/nbs/palace_2d_twmzm.py @@ -340,9 +340,6 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: # %% # -- RF simulation (50 GHz) ------------------------------------------------ -# 2D mode analysis defaults to a single MPI rank + OpenMP threads: Palace's -# SuperLU_DIST direct solve does not scale on small 2D problems (16 MPI -# ranks can effectively hang), so MPI is unnecessary here. results = sim.run_local(verbose=True) results.print() @@ -366,6 +363,7 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: RF_OUTPUT_DIR, field=RF_FIELD, title=RF_FIELD_TITLE, + interactive=True, ) # %% @@ -377,6 +375,7 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: field=RF_FIELD, physical_groups=RIB_PHYSICAL_GROUPS, title="RF Mode |E| in the Rib Waveguide (50 GHz, zoomed)", + interactive=True, ) # %% [markdown] @@ -401,20 +400,20 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: OPT_OUTPUT_DIR = "./palace-sim-mzm-pn-opt" OPT_MESH = { "preset": "default", - "refined_mesh_size": 0.02, + "refined_mesh_size": 0.2, "max_mesh_size": 0.5, } # %% # -- Optical setup + run (1550 nm) --------------------------------------- -sim_opt = BoundaryModeSim() -sim_opt.set_output_dir(OPT_OUTPUT_DIR) -sim_opt.set_stack(stack) -sim_opt.set_airbox(**AIRBOX) -sim_opt.set_geometry(comp) - -sim_opt.set_cross_section(f"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}") -sim_opt.set_boundary_mode( +sim_optical = BoundaryModeSim() +sim_optical.set_output_dir(OPT_OUTPUT_DIR) +sim_optical.set_stack(stack) +sim_optical.set_airbox(**AIRBOX) +sim_optical.set_geometry(comp) + +sim_optical.set_cross_section(f"{CROSS_SECTION_AXIS}={CROSS_SECTION_VALUE}") +sim_optical.set_boundary_mode( freq=F_OPT, num_modes=NUM_OPT_MODES, save=2, @@ -422,7 +421,7 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: tolerance=TOLERANCE, ) -sim_opt.mesh( +sim_optical.mesh( preset=OPT_MESH["preset"], refined_mesh_size=OPT_MESH["refined_mesh_size"], max_mesh_size=OPT_MESH["max_mesh_size"], @@ -430,7 +429,16 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: margin_y=MESH_MARGIN_Y, ) -opt_results = sim_opt.run_local(verbose=True) +# %% +# Interactive 3D mesh visualisation +sim_optical.plot_mesh( + transparent_groups=["air__None", "air__passive", "oxide__passive"], + style="solid", + interactive=True, +) + +# %% +opt_results = sim_optical.run_local(verbose=True) opt_results.print() # %% [markdown] @@ -467,7 +475,7 @@ def centered_rect(wx: float, wy: float, layer) -> gf.Component: # 2. Run `sim.run_local(verbose=True)` with a Palace CPU runner installed (`pip install gsim[palace-toolkit-cpu]`). 2D # mode analysis defaults to a single MPI rank + OpenMP threads; pass `num_processes=1` explicitly if you want # to be explicit about it. -# 3. Run `sim_opt.run_local(verbose=True)` for the optical mode. +# 3. Run `sim_optical.run_local(verbose=True)` for the optical mode. # 4. Use `gsim.palace.plot_fields_2d()` to visualise mode profiles, and `gsim.palace.plot_plane_section()` for cross-section physical groups. # diff --git a/src/gsim/palace/__init__.py b/src/gsim/palace/__init__.py index e0732dd9..836c67cd 100644 --- a/src/gsim/palace/__init__.py +++ b/src/gsim/palace/__init__.py @@ -138,7 +138,15 @@ # Runtime / binary resolution (optional palace-toolkit-cpu dependency) from gsim.palace.runtime import resolve_palace_binary, resolve_palace_library_dir -from gsim.viz import plot_cross_section, plot_mesh +from gsim.viz import ( + close_interactive_view, + close_interactive_views, + interactive_mode, + plot_cross_section, + plot_mesh, + set_interactive_mode, + set_trame_backend, +) __all__ = [ "MATERIALS_DB", @@ -181,6 +189,8 @@ "WavePortConfig", "activate_vector_component", "build_selector_context", + "close_interactive_view", + "close_interactive_views", "configure_cpw_port", "configure_inplane_port", "configure_via_port", @@ -196,6 +206,7 @@ "get_material_properties", "get_port_map", "get_stack", + "interactive_mode", "load_boundary_field_data", "load_field_context", "load_fields", @@ -222,6 +233,8 @@ "resolve_physical_groups", "resolve_scalar_field", "run_simulation", + "set_interactive_mode", + "set_trame_backend", ] diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 70e11460..c51fb3d6 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -792,7 +792,7 @@ def plot_mesh( self, output: str | Path | None = None, show_groups: list[str] | None = None, - interactive: bool = True, + interactive: bool | None = None, style: Literal["wireframe", "solid"] = "wireframe", transparent_groups: list[str] | None = None, ) -> None: @@ -801,11 +801,14 @@ def plot_mesh( Requires mesh() to be called first. Args: - output: Output PNG path (only used if interactive=False) + output: Output PNG path (only used for static rendering). show_groups: List of group name patterns to show (None = all). Example: ["metal", "P"] to show metal layers and ports. - interactive: If True, open interactive 3D viewer. - If False, save static PNG to output path. + interactive: If True, force an interactive 3D viewer (a widget on + the shared trame server in a notebook, or a blocking window + otherwise). If False, save a static PNG. If None (default), + follow the session flag from ``gsim.viz.set_interactive_mode`` + — off by default, so plots render statically until enabled. style: ``"wireframe"`` (edges only) or ``"solid"`` (coloured surfaces per physical group). transparent_groups: Group names rendered at low opacity in @@ -828,8 +831,8 @@ def plot_mesh( if not mesh_path.exists(): raise ValueError(f"Mesh file not found: {mesh_path}. Call mesh() first.") - # Default output path if not interactive - if output is None and not interactive: + # Default output path for the static rendering path. + if output is None and interactive is not True: output = self._output_dir / "mesh.png" _plot_mesh( diff --git a/src/gsim/palace/field_viz.py b/src/gsim/palace/field_viz.py index 886c730e..8a6dc098 100644 --- a/src/gsim/palace/field_viz.py +++ b/src/gsim/palace/field_viz.py @@ -292,6 +292,7 @@ def plot_fields_2d( show_edges: bool = False, opacity: float = 1.0, show: bool = True, + interactive: bool | None = None, screenshot: str | Path | None = None, mode: Literal["auto", "live", "static"] = "auto", ) -> Any: @@ -320,18 +321,26 @@ def plot_fields_2d( title: Plot title. show_edges: Toggle mesh edges on/off. opacity: Opacity of the mesh (0-1). - show: If True, show the interactive PyVista window. - If a live view is already open in this process (or *mode* is - ``"static"``), the plot is rendered to a PNG and displayed inline - instead of opening a second live window. + show: If True, display the plot (live view or static image); if + False, the plotter is created and returned without rendering. + interactive: If ``True``, force an interactive view: in a notebook + this renders as a widget on the single shared trame server (so + several views can coexist); otherwise it opens a blocking window. + ``False`` forces a static PNG. ``None`` (default) follows the + session flag from ``gsim.viz.set_interactive_mode`` — off by + default, so plots render statically until it is enabled. screenshot: If given, save a screenshot to this path. mode: Rendering mode: ``"auto"`` (default), ``"live"`` or ``"static"``. Overrides ``GSIM_VIZ_MODE`` for this call. + Interactive views are off by default; enable them globally with + ``gsim.viz.set_interactive_mode(True)``. Close open views with + ``gsim.viz.close_interactive_views()``. The camera always faces + the cross-section plane, including for interactive views. Returns: The ``pv.Plotter`` instance. """ - from gsim.viz import _ensure_pyvista, _show_or_screenshot + from gsim.viz import _apply_front_camera, _ensure_pyvista, _show_or_screenshot _ensure_pyvista() import pyvista as pv @@ -374,7 +383,7 @@ def plot_fields_2d( dataset = dataset.extract_cells(keep) # Slice to the cross-section plane. - sliced, _used_normal, _axis_idx, axes = _slice_plane( + sliced, _used_normal, _axis_idx, _axes = _slice_plane( dataset, normal=normal, origin=origin, @@ -419,22 +428,14 @@ def plot_fields_2d( ) pl.add_title(title, font_size=12) - pts = sliced.points - h_vals = pts[:, axes[0]] - v_vals = pts[:, axes[1]] - center = ((h_vals.min() + h_vals.max()) / 2, (v_vals.min() + v_vals.max()) / 2) - span = (h_vals.max() - h_vals.min(), v_vals.max() - v_vals.min()) - dist = max(span) * 2.5 if max(span) > 0 else 1.0 - pl.camera.focal_point = (center[0], center[1], 0.0) - pl.camera.position = (center[0], center[1], dist) - + _apply_front_camera(pl, np.asarray(sliced.points), _axis_idx) if screenshot is not None: pl.screenshot(str(screenshot)) pl.close() return pl if show: - _show_or_screenshot(pl, interactive=True, mode=mode) + _show_or_screenshot(pl, interactive=interactive, mode=mode, reset_camera=False) else: pl.close() return pl diff --git a/src/gsim/viz.py b/src/gsim/viz.py index 41e95665..e2d0913e 100644 --- a/src/gsim/viz.py +++ b/src/gsim/viz.py @@ -5,8 +5,20 @@ from __future__ import annotations -__all__ = ["plot_cross_section", "plot_mesh", "plot_topview", "sample_topview_field"] - +__all__ = [ + "close_interactive_view", + "close_interactive_views", + "interactive_mode", + "interactive_views", + "plot_cross_section", + "plot_mesh", + "plot_topview", + "sample_topview_field", + "set_interactive_mode", + "set_trame_backend", +] + +import contextlib import hashlib import logging import os @@ -26,6 +38,55 @@ #: process as static inline images (avoids concurrent live trame servers). _VIZ_MODE = os.environ.get("GSIM_VIZ_MODE", "auto").strip().lower() or "auto" +#: Trame backend used for notebook live views. ``"trame"`` uses server-side +#: rendering (PyVista's default and most reliable); ``"client"`` renders +#: in-browser with vtk.js. Override with ``GSIM_TRAME_BACKEND``. +_TRAME_BACKEND = os.environ.get("GSIM_TRAME_BACKEND", "trame").strip().lower() + + +def set_trame_backend(backend: Literal["trame", "client"]) -> None: + """Set the trame backend used for notebook live views. + + ``"trame"`` (default) uses server-side rendering; ``"client"`` renders + in-browser with vtk.js. Also configurable via the ``GSIM_TRAME_BACKEND`` + environment variable. + """ + global _TRAME_BACKEND # noqa: PLW0603 + if backend not in ("trame", "client"): + msg = f"backend must be 'trame' or 'client', got {backend!r}" + raise ValueError(msg) + _TRAME_BACKEND = backend + + +#: Master switch for interactive (live) PyVista views. Off by default: every +#: plot renders to a static image. When enabled via :func:`set_interactive_mode`, +#: interactive views are allowed: in a notebook each interactive plot renders as +#: its own widget on a single shared trame server; outside a notebook the plot +#: opens a blocking desktop window. All live views stay registered until closed +#: with :func:`close_interactive_views` (or :func:`close_interactive_view`). +_INTERACTIVE_MODE = False + + +def set_interactive_mode(enabled: bool) -> None: + """Enable or disable interactive (live) PyVista views. + + Defaults to ``False``: every plot renders to a static image. When + enabled, interactive views are allowed. In a notebook each interactive + plot renders as its own widget on a single shared trame server, so several + views can stay open at once; outside a notebook the plot opens a blocking + desktop window. Close the open views with :func:`close_interactive_views`. + + An explicit per-call ``mode="live"`` (or ``GSIM_VIZ_MODE=live``) always + overrides the flag. + """ + global _INTERACTIVE_MODE # noqa: PLW0603 + _INTERACTIVE_MODE = bool(enabled) + + +def interactive_mode() -> bool: + """Return whether interactive (live) PyVista views are currently enabled.""" + return _INTERACTIVE_MODE + # -- Headless-safe PyVista initialisation ------------------------------------ def _is_headless() -> bool: @@ -92,7 +153,7 @@ def plot_mesh( msh_path: str | Path, output: str | Path | None = None, show_groups: list[str] | None = None, - interactive: bool = True, + interactive: bool | None = None, style: Literal["wireframe", "solid"] = "wireframe", transparent_groups: list[str] | None = None, mode: RenderMode = "auto", @@ -112,14 +173,20 @@ def plot_mesh( output: Output PNG path (only used when rendering statically). show_groups: Group-name patterns to display (``None`` -> all). Example: ``["metal", "P"]`` to show metal layers and ports. - interactive: If ``True``, request an interactive 3-D view. If a live - view is already open in this process (or *mode* is ``"static"``) - the plot is rendered to a PNG instead. + interactive: If ``True``, force an interactive view: in a notebook + this renders as a widget on the single shared trame server (so + several views can coexist); otherwise it opens a blocking window. + ``False`` forces a static PNG. ``None`` (default) follows the + session flag from :func:`set_interactive_mode` — off by default, + so plots render statically until it is enabled. style: ``"wireframe"`` or ``"solid"``. transparent_groups: Group names rendered at low opacity in *solid* mode. Ignored in *wireframe* mode. mode: Rendering mode: ``"auto"`` (default), ``"live"`` or ``"static"``. - Overrides ``GSIM_VIZ_MODE`` for this call. + Overrides ``GSIM_VIZ_MODE`` for this call. By default interactive + views are off; enable them globally with + :func:`set_interactive_mode`. Close open views with + :func:`close_interactive_views`. Example: >>> pa.plot_mesh("./sim/palace.msh", show_groups=["metal", "P"]) @@ -157,7 +224,7 @@ def _plot_wireframe( *, output: str | Path | None, show_groups: list[str] | None, - interactive: bool, + interactive: bool | None, mode: RenderMode = "auto", ) -> None: """Wireframe renderer — one colour per matched group.""" @@ -189,7 +256,19 @@ def _plot_wireframe( else: plotter.add_mesh(mesh, style="wireframe", color="black", line_width=1) - _finish(plotter, msh_path, output=output, interactive=interactive, mode=mode) + reset_camera = True + axis_idx = _dataset_is_planar(mesh) + if axis_idx is not None: + _apply_front_camera(plotter, np.asarray(mesh.points), axis_idx) + reset_camera = False + _finish( + plotter, + msh_path, + output=output, + interactive=interactive, + mode=mode, + reset_camera=reset_camera, + ) # --------------------------------------------------------------------------- @@ -275,7 +354,7 @@ def _plot_solid( msh_path: Path, *, output: str | Path | None, - interactive: bool, + interactive: bool | None, transparent_groups: list[str], mode: RenderMode = "auto", ) -> None: @@ -403,7 +482,19 @@ def _plot_solid( if transparent_legend_entries: plotter.add_legend(transparent_legend_entries, bcolor="white", border=True) - _finish(plotter, msh_path, output=output, interactive=interactive, mode=mode) + reset_camera = True + axis_idx = _dataset_is_planar(grid) + if axis_idx is not None: + _apply_front_camera(plotter, np.asarray(grid.points), axis_idx) + reset_camera = False + _finish( + plotter, + msh_path, + output=output, + interactive=interactive, + mode=mode, + reset_camera=reset_camera, + ) # --------------------------------------------------------------------------- @@ -411,33 +502,154 @@ def _plot_solid( # --------------------------------------------------------------------------- -# Tracks whether a live (interactive) PyVista view is open. Once a view has -# been shown live, subsequent interactive plots render statically unless -# explicitly forced with ``mode="live"`` (or ``GSIM_VIZ_MODE=live``). -_LIVE_PLOTTER_OPEN = False +# Live (interactive) PyVista plotters currently held open, keyed by plotter id. +# Keeping the plotters referenced keeps their render windows / trame viewers +# alive. In a notebook every interactive plot adds its own widget to the +# single shared trame server; outside a notebook a live plot blocks the +# process until its window is closed. +_LIVE_PLOTTERS: dict[str, Any] = {} + + +def _view_id(plotter: Any) -> str: + """Return a stable registry key for a PyVista plotter.""" + name = getattr(plotter, "_id_name", None) + if name is not None: + return str(name) + return f"plotter-{id(plotter)}" + + +def _in_notebook() -> bool: + """Return ``True`` when running inside a Jupyter/IPython kernel.""" + try: + import scooby + + return bool(scooby.in_ipykernel()) + except Exception: + return False + + +def _close_live_views() -> None: + """Close every currently open live view and clear the registry.""" + for view_id, plotter in list(_LIVE_PLOTTERS.items()): + try: + plotter.close() + except Exception: + logger.warning("Failed to close live view '%s'", view_id, exc_info=True) + _LIVE_PLOTTERS.clear() + + +def close_interactive_views() -> None: + """Close every interactive (live) view opened in this process. + + Interactive views are registered while they stay open (see + :func:`set_interactive_mode`); calling this releases their render windows + and trame server resources. + """ + _close_live_views() + + +def close_interactive_view(view_id: str) -> None: + """Close a single interactive view by its plotter id. + + Args: + view_id: Plotter id as used in the live-view registry (see + :func:`interactive_views`). + """ + plotter = _LIVE_PLOTTERS.pop(view_id, None) + if plotter is None: + logger.warning("No interactive view registered with id %r", view_id) + return + try: + plotter.close() + except Exception: + logger.warning("Failed to close interactive view '%s'", view_id, exc_info=True) + + +def interactive_views() -> tuple[str, ...]: + """Return the ids of all interactive views currently held open.""" + return tuple(_LIVE_PLOTTERS) + + +def _dataset_is_planar(dataset: pv.DataSet) -> int | None: + """Return the axis index a dataset is thin along (a 2D plane), else ``None``.""" + bounds = np.asarray(dataset.bounds, dtype=float).reshape(3, 2) + span = bounds[:, 1] - bounds[:, 0] + span_ref = max(float(np.max(span)), 1.0) + thin = np.where(span <= 1e-9 * span_ref)[0] + if thin.size == 1: + return int(thin[0]) + return None + + +def _apply_front_camera(plotter: Any, points: np.ndarray, axis_idx: int) -> None: + """Point a plotter camera straight at a planar point cloud. + + The camera is positioned along the plane's normal *axis_idx*, facing the + centre of the point cloud, with the plot's vertical axis aligned to the + in-plane axis so the result is not rolled. + """ + axes = [i for i in range(3) if i != axis_idx] + h = points[:, axes[0]] + v = points[:, axes[1]] + center = ((h.min() + h.max()) / 2, (v.min() + v.max()) / 2) + span = (h.max() - h.min(), v.max() - v.min()) + dist = max(span) * 2.5 if max(span) > 0 else 1.0 + normal_origin = float(points[:, axis_idx].min()) + focal = [0.0, 0.0, 0.0] + focal[axes[0]] = center[0] + focal[axes[1]] = center[1] + focal[axis_idx] = normal_origin + position = list(focal) + position[axis_idx] += dist + up = [0.0, 0.0, 0.0] + up[axes[1]] = 1.0 + plotter.camera.focal_point = focal + plotter.camera.position = position + plotter.camera.up = up def _mode_for(mode: RenderMode) -> RenderMode: """Resolve the effective rendering mode for a plot. - ``auto`` preserves the interactive PyVista window for the first view; - additional views in the same process fall back to static rendering via - the live-view guard in :func:`_show_or_screenshot`. ``GSIM_VIZ_MODE`` - can force ``"live"`` or ``"static"`` globally. + ``auto`` renders statically unless interactive mode is enabled via + :func:`set_interactive_mode`, in which case live rendering is used. In a + notebook, live plots share a single trame server (one widget per plot); + outside a notebook a live plot opens a blocking desktop window. + ``GSIM_VIZ_MODE=live`` forces live rendering; ``GSIM_VIZ_MODE=static`` + (or ``none``/``off``) forces static rendering globally. """ if mode != "auto": return mode - return "live" if _VIZ_MODE not in ("static", "none", "off") else "static" + if _VIZ_MODE == "live": + return "live" + if _INTERACTIVE_MODE and _VIZ_MODE not in ("static", "none", "off"): + return "live" + return "static" + + +def _want_live(interactive: bool | None, mode: RenderMode) -> bool: + """Return whether a plot should be shown live. + + ``interactive=True`` forces a live view (unless an explicit ``mode`` / + ``GSIM_VIZ_MODE`` static override is in force). ``interactive=False`` + forces a static screenshot. ``interactive=None`` (default) follows the + session flag from :func:`set_interactive_mode` — off by default. + """ + if interactive is False: + return False + if _mode_for(mode) == "live": + return True + return ( + interactive is True + and mode == "auto" + and _VIZ_MODE not in ("static", "none", "off") + ) -def _make_plotter(interactive: bool, *, mode: RenderMode = "auto") -> Any: +def _make_plotter(interactive: bool | None, *, mode: RenderMode = "auto") -> Any: """Create a PyVista plotter with standard window settings.""" pv = _ensure_pyvista() - off_screen = ( - not interactive - or _mode_for(mode) == "static" - or (_LIVE_PLOTTER_OPEN and mode != "live") - ) + off_screen = not _want_live(interactive, mode) density = {"window_size": [1200, 900]} if off_screen: plotter = pv.Plotter(off_screen=True, **density) @@ -447,42 +659,94 @@ def _make_plotter(interactive: bool, *, mode: RenderMode = "auto") -> Any: return plotter +def _show_static(plotter: Any) -> None: + """Render a plotter to a temporary PNG and display it inline. + + Used as a fallback when an interactive trame widget cannot be created, so + the plot is still shown rather than silently dropped. + """ + dest = _default_output(None, ".png") + with contextlib.suppress(Exception): + plotter.render() + try: + plotter.screenshot(str(dest)) + except Exception: + logger.warning("Failed to render fallback static image", exc_info=True) + dest = None + finally: + with contextlib.suppress(Exception): + plotter.close() + if dest is not None and dest.exists(): + _disp_img(dest) + + +def _show_live(plotter: Any) -> None: + """Show a plotter live and register it in the open-views registry. + + In a notebook the plot is rendered as an interactive widget on the single + shared trame server (server-side rendering by default, see + :func:`set_trame_backend`), so any number of interactive views can coexist + on one server. PyVista displays the widget itself; this function only + renders the scene first and keeps the plotter alive in the registry. If + the widget cannot be created, a static image is shown instead. Outside a + notebook a blocking desktop window is opened, replacing any previously + open live view. + """ + if _in_notebook() and getattr(plotter, "notebook", False): + try: + plotter.render() + plotter.show(jupyter_backend=_TRAME_BACKEND) + except Exception: + logger.warning( + "Failed to render interactive trame widget; showing a static image", + exc_info=True, + ) + _show_static(plotter) + return + _LIVE_PLOTTERS[_view_id(plotter)] = plotter + return + _close_live_views() + plotter.show() + _LIVE_PLOTTERS[_view_id(plotter)] = plotter + + def _show_or_screenshot( plotter: Any, *, msh_path: Path | None = None, output: str | Path | None = None, - interactive: bool, + interactive: bool | None, mode: RenderMode = "auto", suffix: str = ".png", + reset_camera: bool = True, ) -> None: """Show a plotter live, or screenshot it, and clean up. - Only one live PyVista view is kept open per process. When *interactive* - is set but a live view is already open (common in notebooks that render - several plots in one kernel), the plot is rendered to a temporary PNG and - displayed inline instead of spawning a second concurrent live trame - server, which otherwise causes blank windows / cache ``KeyError`` errors. - Passing ``mode="live"`` lets a caller force a second live view; passing - ``mode="static"`` (or setting ``GSIM_VIZ_MODE=static``) renders statically. - """ - global _LIVE_PLOTTER_OPEN # noqa: PLW0603 + Interactive (live) views are disabled by default; enable them globally + with :func:`set_interactive_mode` or force them per call by passing + ``interactive=True``. When the plot is shown live it is registered in + the open-views registry (see :func:`interactive_views`): in a notebook it + renders as its own widget on the single shared trame server, so several + views can coexist; outside a notebook it opens a blocking desktop window, + replacing any previously open live view. Close open views with + :func:`close_interactive_views`. - plotter.camera_position = "iso" + Otherwise the plot is rendered to a static PNG and displayed inline. + + When *reset_camera* is ``True`` (default) the camera is reset to an + isometric view; pass ``False`` to preserve a camera already configured on + the plotter (used by ``plot_fields_2d`` to face the slice plane). + """ + if reset_camera: + plotter.camera_position = "iso" plotter.show_axes() - resolved = _mode_for(mode) - if ( - interactive - and resolved == "live" - and (mode == "live" or not _LIVE_PLOTTER_OPEN) - ): - _LIVE_PLOTTER_OPEN = True - plotter.show() + if _want_live(interactive, mode): + _show_live(plotter) return - # Static screenshot: either interactive=False, forced static, or a live - # view is already open elsewhere in this process. + # Static screenshot: either interactive=False or the effective mode is + # "static". dest = output if output is not None else _default_output(msh_path, suffix) plotter.screenshot(str(dest)) plotter.close() @@ -519,12 +783,18 @@ def _finish( msh_path: Path, *, output: str | Path | None = None, - interactive: bool, + interactive: bool | None, mode: RenderMode = "auto", + reset_camera: bool = True, ) -> None: """Show or screenshot the plotter and clean up.""" _show_or_screenshot( - plotter, msh_path=msh_path, output=output, interactive=interactive, mode=mode + plotter, + msh_path=msh_path, + output=output, + interactive=interactive, + mode=mode, + reset_camera=reset_camera, ) diff --git a/tests/palace/test_field_viz.py b/tests/palace/test_field_viz.py index 50dc08c9..b58335bb 100644 --- a/tests/palace/test_field_viz.py +++ b/tests/palace/test_field_viz.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import matplotlib as mpl import numpy as np import pytest @@ -96,6 +98,31 @@ def test_plot_fields_2d_missing_field_raises(planar_vector_dataset: pv.DataSet) ) +def test_plot_fields_2d_forwards_interactive_flag( + monkeypatch: pytest.MonkeyPatch, + planar_vector_dataset: pv.DataSet, +) -> None: + """The ``interactive`` flag is forwarded to the display helper.""" + import gsim.viz as viz + + captured: dict[str, object] = {} + + def _spy(plotter: Any, **_kwargs: object) -> None: + captured["interactive"] = _kwargs.get("interactive") + plotter.close() + + monkeypatch.setattr(viz, "_show_or_screenshot", _spy) + + field_viz.plot_fields_2d( + planar_vector_dataset, + normal="z", + origin=0.0, + show=True, + interactive=False, + ) + assert captured["interactive"] is False + + def test_extract_streamplot_inputs_2d(planar_vector_dataset: pv.DataSet) -> None: """Streamplot input extraction should return PalaceToolkit-style arrays.""" out = field_viz.extract_streamplot_inputs_2d( diff --git a/tests/test_viz_mesh.py b/tests/test_viz_mesh.py index 7573f2f3..111b2d31 100644 --- a/tests/test_viz_mesh.py +++ b/tests/test_viz_mesh.py @@ -4,6 +4,7 @@ import sys from pathlib import Path +from typing import Any import meshio import numpy as np @@ -211,21 +212,79 @@ def test_plot_solid_skips_unsupported_blocks(tmp_path: Path) -> None: assert out.exists() +@_SKIP_RENDER_ON_WIN +def test_plot_mesh_2d_camera_faces_plane( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """plot_mesh on a 2D mesh points the camera straight at the plane.""" + msh = tmp_path / "flat.msh" + _write_minimal_msh(msh) + + captured: dict[str, object] = {} + + def _spy(plotter: Any, **_kwargs: object) -> None: + captured["camera"] = plotter.camera + plotter.close() + + monkeypatch.setattr(viz, "_show_or_screenshot", _spy) + + viz.plot_mesh(msh, interactive=True, mode="live") + + camera: Any = captured["camera"] + view = np.array(camera.position) - np.array(camera.focal_point) + view /= np.linalg.norm(view) + # The fixture mesh is thin along z, so the camera must face +z/-z. + assert np.allclose(np.abs(view), [0.0, 0.0, 1.0]) + + +@_SKIP_RENDER_ON_WIN +def test_plot_mesh_3d_keeps_iso_camera( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """plot_mesh on a 3D mesh keeps the default isometric view.""" + msh = tmp_path / "vol.msh" + _write_minimal_msh(msh, use_3d=True) + + captured: dict[str, object] = {} + + def _spy(plotter: Any, **_kwargs: object) -> None: + captured["camera"] = plotter.camera + plotter.close() + + monkeypatch.setattr(viz, "_show_or_screenshot", _spy) + + viz.plot_mesh(msh, interactive=True, mode="live") + + camera: Any = captured["camera"] + view = np.array(camera.position) - np.array(camera.focal_point) + view /= np.linalg.norm(view) + # Isometric view is not aligned to a single axis. + assert sum(abs(d) > 0.5 for d in view) > 1 + + class _FakePlotter: """Minimal stand-in for a PyVista plotter in mode-resolution tests.""" + _id_counter = 0 camera_position = None def __init__(self) -> None: self.shown = 0 self.screenshotted = 0 self.closed = 0 + self.notebook = False + self._id_name = f"fake-{_FakePlotter._id_counter}" + _FakePlotter._id_counter += 1 def show_axes(self) -> None: return None - def show(self) -> None: + def render(self) -> None: + return None + + def show(self, **_kwargs: object) -> _FakePlotter: self.shown += 1 + return self def screenshot(self, _path: object) -> None: self.screenshotted += 1 @@ -234,54 +293,237 @@ def close(self) -> None: self.closed += 1 -def test_show_or_screenshot_first_live_then_static( +def test_show_or_screenshot_static_when_interactive_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With the session flag off, auto (interactive=None) renders a static PNG.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + monkeypatch.setattr(viz, "_in_notebook", lambda: False) + + plotter = _FakePlotter() + viz._show_or_screenshot(plotter, interactive=None, mode="auto") + assert plotter.shown == 0 + assert plotter.screenshotted == 1 + assert viz._LIVE_PLOTTERS == {} + + +def test_interactive_true_forces_live_even_when_off( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Auto mode keeps the first interactive view, renders later ones as static PNGs.""" - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + """interactive=True forces a live view even when the session flag is off.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + monkeypatch.setattr(viz, "_in_notebook", lambda: False) + + plotter = _FakePlotter() + viz._show_or_screenshot(plotter, interactive=True, mode="auto") + assert plotter.shown == 1 + assert plotter.screenshotted == 0 + assert {plotter._id_name: plotter} == viz._LIVE_PLOTTERS + + +def test_desktop_live_replaces_previous(monkeypatch: pytest.MonkeyPatch) -> None: + """Outside a notebook, a live plot replaces any previously open live view.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) + monkeypatch.setattr(viz, "_in_notebook", lambda: False) first = _FakePlotter() viz._show_or_screenshot(first, interactive=True, mode="auto") assert first.shown == 1 assert first.screenshotted == 0 - assert viz._LIVE_PLOTTER_OPEN + assert {first._id_name: first} == viz._LIVE_PLOTTERS second = _FakePlotter() viz._show_or_screenshot(second, interactive=True, mode="auto") - assert second.shown == 0 - assert second.screenshotted == 1 - assert second.closed == 1 + assert second.shown == 1 + assert first.closed == 1 + assert {second._id_name: second} == viz._LIVE_PLOTTERS - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) +def test_notebook_keeps_multiple_views(monkeypatch: pytest.MonkeyPatch) -> None: + """In a notebook each interactive plot adds a widget to the shared trame server.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) + monkeypatch.setattr(viz, "_in_notebook", lambda: True) -def test_mode_live_forces_show(monkeypatch: pytest.MonkeyPatch) -> None: - """mode='live' forces a live view even when one is already open.""" - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", True) + first = _FakePlotter() + first.notebook = True + viz._show_or_screenshot(first, interactive=True, mode="auto") + assert first.shown == 1 + assert first.closed == 0 + + second = _FakePlotter() + second.notebook = True + viz._show_or_screenshot(second, interactive=True, mode="auto") + assert second.shown == 1 + assert second.closed == 0 + assert first.closed == 0 # previous view stays alive + assert len(viz._LIVE_PLOTTERS) == 2 + + +def test_notebook_uses_trame_backend_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Notebook live views use server-side trame rendering by default.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_in_notebook", lambda: True) + monkeypatch.setattr(viz, "_TRAME_BACKEND", "trame") + + seen: dict[str, object] = {} + plotter = _FakePlotter() + plotter.notebook = True + + def _show(**_kwargs: object) -> _FakePlotter: + seen["backend"] = _kwargs.get("jupyter_backend") + return plotter + + monkeypatch.setattr(plotter, "show", _show) + + viz._show_or_screenshot(plotter, interactive=True, mode="live") + assert seen["backend"] == "trame" + assert {plotter._id_name: plotter} == viz._LIVE_PLOTTERS + + +def test_set_trame_backend_validates() -> None: + """set_trame_backend accepts trame/client and rejects anything else.""" + viz.set_trame_backend("client") + assert viz._TRAME_BACKEND == "client" + viz.set_trame_backend("trame") + assert viz._TRAME_BACKEND == "trame" + with pytest.raises(ValueError, match="backend"): + viz.set_trame_backend("bogus") # ty: ignore[invalid-argument-type] + + +def test_notebook_widget_failure_falls_back_to_static( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the interactive widget cannot be created, show a static image instead.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) + monkeypatch.setattr(viz, "_in_notebook", lambda: True) + + plotter = _FakePlotter() + plotter.notebook = True + + def _broken_show(**_kwargs: object) -> None: + raise RuntimeError("trame widget failed") + + monkeypatch.setattr(plotter, "show", _broken_show) + + viz._show_or_screenshot(plotter, interactive=True, mode="auto") + assert plotter.shown == 0 + assert plotter.screenshotted == 1 + assert plotter.closed == 1 + assert viz._LIVE_PLOTTERS == {} + + +def test_mode_live_forces_show_desktop(monkeypatch: pytest.MonkeyPatch) -> None: + """mode='live' forces a live view, replacing any open view outside a notebook.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + monkeypatch.setattr(viz, "_in_notebook", lambda: False) + + prev = _FakePlotter() + viz._LIVE_PLOTTERS[prev._id_name] = prev forced = _FakePlotter() viz._show_or_screenshot(forced, interactive=True, mode="live") assert forced.shown == 1 - - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + assert prev.closed == 1 def test_mode_static_always_screenshots(monkeypatch: pytest.MonkeyPatch) -> None: """mode='static' renders a screenshot even before any live view.""" - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) static = _FakePlotter() viz._show_or_screenshot(static, interactive=True, mode="static") assert static.shown == 0 assert static.screenshotted == 1 + assert viz._LIVE_PLOTTERS == {} + + +def test_interactive_false_screenshots_even_in_live_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """interactive=False forces a static screenshot even when mode='live'.""" + viz._LIVE_PLOTTERS.clear() + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) + monkeypatch.setattr(viz, "_in_notebook", lambda: True) + + plotter = _FakePlotter() + viz._show_or_screenshot(plotter, interactive=False, mode="live") + assert plotter.shown == 0 + assert plotter.screenshotted == 1 + assert viz._LIVE_PLOTTERS == {} + + +def test_close_interactive_views() -> None: + """close_interactive_views closes every registered view and clears the registry.""" + viz._LIVE_PLOTTERS.clear() + a, b = _FakePlotter(), _FakePlotter() + viz._LIVE_PLOTTERS[a._id_name] = a + viz._LIVE_PLOTTERS[b._id_name] = b + + viz.close_interactive_views() + assert a.closed == 1 + assert b.closed == 1 + assert viz._LIVE_PLOTTERS == {} + + +def test_close_interactive_view_single() -> None: + """close_interactive_view closes one registered view by id.""" + viz._LIVE_PLOTTERS.clear() + a, b = _FakePlotter(), _FakePlotter() + viz._LIVE_PLOTTERS[a._id_name] = a + viz._LIVE_PLOTTERS[b._id_name] = b + + viz.close_interactive_view(a._id_name) + assert a.closed == 1 + assert b.closed == 0 + assert a._id_name not in viz._LIVE_PLOTTERS + + +def test_interactive_views_lists_ids() -> None: + """interactive_views returns the ids of all open views.""" + viz._LIVE_PLOTTERS.clear() + a = _FakePlotter() + viz._LIVE_PLOTTERS[a._id_name] = a + assert viz.interactive_views() == (a._id_name,) + + +def test_mode_for_respects_interactive_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """auto is static by default and live only when interactive mode is enabled.""" + monkeypatch.setattr(viz, "_VIZ_MODE", "auto") - monkeypatch.setattr(viz, "_LIVE_PLOTTER_OPEN", False) + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + assert viz._mode_for("auto") == "static" + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", True) + assert viz._mode_for("auto") == "live" -def test_mode_for_respects_global_static(monkeypatch: pytest.MonkeyPatch) -> None: - """GSIM_VIZ_MODE=static makes 'auto' resolve to static.""" monkeypatch.setattr(viz, "_VIZ_MODE", "static") assert viz._mode_for("auto") == "static" - assert viz._mode_for("live") == "live" - monkeypatch.setattr(viz, "_VIZ_MODE", "auto") + + monkeypatch.setattr(viz, "_VIZ_MODE", "live") assert viz._mode_for("auto") == "live" + + monkeypatch.setattr(viz, "_VIZ_MODE", "auto") + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + assert viz._mode_for("auto") == "static" + assert viz._mode_for("live") == "live" + assert viz._mode_for("static") == "static" + monkeypatch.setattr(viz, "_INTERACTIVE_MODE", False) + + +def test_set_interactive_mode_toggles_flag() -> None: + """The interactive mode flag is off by default and toggles via the setter.""" + viz.set_interactive_mode(False) + assert viz.interactive_mode() is False + viz.set_interactive_mode(True) + assert viz.interactive_mode() is True + viz.set_interactive_mode(False) + assert viz.interactive_mode() is False From 5389c893dda765267d0a4cb8d4436ff1ecdd52a4 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 10 Aug 2026 18:03:48 -0300 Subject: [PATCH 6/6] feat: optical-only cross-section in uniform SiO2 cladding (no air) Adds a simplified all-dielectric cross-section for the TW-MZM optical analysis and makes the Palace 2D background medium configurable. - build_optical_cross_section(): minimal LayerStack where rib/slab/PN all map to plain Si, embedded in a single uniform SiO2 cladding. - set_airbox(material=...): retunes the native-2D BoundaryMode background from air to a cladding material (e.g. "sio2"); default unchanged. The unclaimed domain merges into one SiO2 physical group, so no air volume is emitted. mesh() margin overrides now preserve the material. - Palace boundary-mode configs resolve material dispersion at the operating frequency (Sellmeier Si/SiO2 at 1550 nm) like driven sims. - Quiet trame's debug INFO logging while live views are built. - Notebook: split RF/optical components, run the optical sim on the all-Si rib+slab+PN stack in a uniform SiO2 background, and fix the optical mode-plot cell (field + physical-group constants). --- nbs/palace_2d_twmzm.ipynb | 4103 +++++++++++++++++++++- nbs/palace_2d_twmzm.py | 296 +- src/gsim/common/cross_section.py | 117 + src/gsim/palace/base.py | 22 +- src/gsim/palace/boundarymode.py | 2 +- src/gsim/palace/driven.py | 2 +- src/gsim/palace/eigenmode.py | 2 +- src/gsim/palace/electrostatic.py | 2 +- src/gsim/palace/mesh/config_generator.py | 14 +- src/gsim/palace/mesh/generator.py | 44 +- src/gsim/viz.py | 32 +- tests/common/test_cross_section.py | 82 + tests/palace/test_config_dispersion.py | 101 +- tests/palace/test_mesh_integration.py | 58 + tests/palace/test_sim_classes.py | 61 +- tests/test_viz_mesh.py | 46 +- 16 files changed, 4830 insertions(+), 154 deletions(-) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index b25e179b..c49ed70a 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -50,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "2", "metadata": {}, "outputs": [], @@ -113,13 +113,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "4", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "import gdsfactory as gf\n", "\n", + "from gsim.common.cross_section import build_optical_cross_section\n", "from gsim.common.stack.doping import make_doping_profile\n", "\n", "gf.gpdk.PDK.activate()\n", @@ -133,56 +145,86 @@ " return r\n", "\n", "\n", - "# --- Component: TW-MZM cross-section ------------------------------------\n", - "comp = gf.Component()\n", + "def _add_device_core(comp: gf.Component) -> None:\n", + " \"\"\"Rib + slab + PN junction — shared by the RF and optical components.\n", "\n", - "# 1. Rib waveguide core (PN junction sits inside it)\n", - "wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", - "wg.y = RIB_CENTER_Y\n", + " The P/N rectangles are the same \"doping profile\" polygons in both, so the\n", + " optical cross-section still shows the junction shape. The optical stack\n", + " maps all four regions to plain silicon.\n", + " \"\"\"\n", + " # 1. Rib waveguide core (PN junction sits inside it)\n", + " wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", + " wg.y = RIB_CENTER_Y\n", "\n", - "# 2. Slab (90 nm)\n", - "slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", - "slab.y = 0.0\n", + " # 2. Slab (90 nm)\n", + " slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", + " slab.y = 0.0\n", "\n", - "# 3. PN junction (P above / N below the rib centre)\n", - "p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", - "p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4\n", - "n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N)\n", - "n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4\n", + " # 3. PN junction (P above / N below the rib centre)\n", + " p_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.P)\n", + " p_half.y = RIB_CENTER_Y + RIB_WIDTH / 4\n", + " n_half = comp << gf.c.rectangle((LENGTH, RIB_WIDTH / 2), layer=LAYER.N)\n", + " n_half.y = RIB_CENTER_Y - RIB_WIDTH / 4\n", "\n", - "# 4. Graded N+/P+ slab doping (contiguous, no gaps)\n", - "doping_result = make_doping_profile(\n", - " comp,\n", - " length=LENGTH,\n", - " rib_center_y=RIB_CENTER_Y,\n", - " rib_width=RIB_WIDTH,\n", - " profile=DOPING_PROFILE,\n", - " sides=DOPING_SIDES,\n", - " zmin=0.0,\n", - " zmax=SLAB_THICKNESS,\n", - " permittivity=SI_PERMITTIVITY,\n", - " fmax=FMAX_RF_MATERIAL,\n", - ")\n", "\n", - "# 5. CPW electrodes (M1)\n", - "sig = comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1)\n", - "gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", - "gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2\n", - "gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", - "gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2)\n", + "def _build_rf_component() -> tuple[gf.Component, dict]:\n", + " \"\"\"Full TW-MZM cross-section: device core + graded doping + CPW + vias.\"\"\"\n", + " comp = gf.Component()\n", + " _add_device_core(comp)\n", "\n", - "# 6. Vias at x=0 (signal-to-P+, ground-to-N+)\n", - "via_s_to_p = comp << gf.c.via_stack(\n", - " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", - ")\n", - "via_s_to_p.x = 0.0\n", - "via_s_to_p.y = VIA_S_TO_P_Y\n", + " # 4. Graded N+/P+ slab doping (contiguous, no gaps)\n", + " doping_result = make_doping_profile(\n", + " comp,\n", + " length=LENGTH,\n", + " rib_center_y=RIB_CENTER_Y,\n", + " rib_width=RIB_WIDTH,\n", + " profile=DOPING_PROFILE,\n", + " sides=DOPING_SIDES,\n", + " zmin=0.0,\n", + " zmax=SLAB_THICKNESS,\n", + " permittivity=SI_PERMITTIVITY,\n", + " fmax=FMAX_RF_MATERIAL,\n", + " )\n", "\n", - "via_g_to_n = comp << gf.c.via_stack(\n", - " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", - ")\n", - "via_g_to_n.x = 0.0\n", - "via_g_to_n.y = VIA_G_TO_N_Y\n", + " # 5. CPW electrodes (M1)\n", + " comp << centered_rect(LENGTH, SIG_WIDTH, LAYER.M1)\n", + " gnd_top = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + " gnd_top.y = SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2\n", + " gnd_bot = comp << centered_rect(LENGTH, GND_WIDTH, LAYER.M1)\n", + " gnd_bot.y = -(SIG_WIDTH / 2 + GAP_WIDTH + GND_WIDTH / 2)\n", + "\n", + " # 6. Vias at x=0 (signal-to-P+, ground-to-N+)\n", + " via_s_to_p = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", + " )\n", + " via_s_to_p.x = 0.0\n", + " via_s_to_p.y = VIA_S_TO_P_Y\n", + "\n", + " via_g_to_n = comp << gf.c.via_stack(\n", + " layers=(\"SLAB90\", \"M1\"), vias=(\"viac\", None), size=(VIA_SIZE, VIA_SIZE)\n", + " )\n", + " via_g_to_n.x = 0.0\n", + " via_g_to_n.y = VIA_G_TO_N_Y\n", + "\n", + " return comp, doping_result\n", + "\n", + "\n", + "def _build_optical_component() -> gf.Component:\n", + " \"\"\"Optical-only cross-section: rib + slab + PN junction (all silicon).\n", + "\n", + " No electrodes, vias, or graded doping — the optical mode sees a single\n", + " homogeneous Si body embedded in the uniform SiO2 cladding stack.\n", + " \"\"\"\n", + " comp = gf.Component()\n", + " _add_device_core(comp)\n", + " return comp\n", + "\n", + "\n", + "# --- RF component (electrodes, vias, graded doping) ------------------------\n", + "comp, doping_result = _build_rf_component()\n", + "\n", + "# --- Optical component (rib + slab + PN junction only) ---------------------\n", + "comp_optical = _build_optical_component()\n", "\n", "# -- Plot ----------------------------------------------------------------------\n", "_cc = comp.copy()\n", @@ -204,10 +246,37 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "6", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO M1 updated: zmin=1.1, zmax=2.1, thickness=1.0\n", + "INFO Stack: generic\n", + "INFO Layers: ['box', 'clad', 'core', 'deep_etch', 'ge', 'heater', 'metal1', 'metal2', 'metal3', 'n_rib', 'nitride', 'npp_slab_0', 'npp_slab_1', 'p_rib', 'pp_slab_0', 'pp_slab_1', 'shallow_etch', 'slab150', 'slab90', 'undercut', 'via1', 'via2', 'via_contact']\n", + "INFO Cross materials: ['n_rib', 'npp_slab_0', 'npp_slab_1', 'p_rib', 'pp_slab_0', 'pp_slab_1']\n", + "INFO Dielectrics: [{'name': 'oxide', 'zmin': -2.0, 'zmax': 5.2, 'material': 'sio2'}, {'name': 'passive', 'zmin': 5.2, 'zmax': 5.6000000000000005, 'material': 'passive'}]\n", + "INFO \n", + "INFO Cross-section x=0.0 intersects 13 layer regions:\n", + "INFO core material=si y=[ -20.200, -19.800] z=[ 0.000, 0.220]\n", + "INFO slab90 material=si y=[ -50.200, 50.200] z=[ 0.000, 0.090]\n", + "INFO via_contact material=Aluminum y=[ -29.350, -28.650] z=[ 0.090, 1.100]\n", + "INFO via_contact material=Aluminum y=[ -9.350, -8.650] z=[ 0.090, 1.100]\n", + "INFO metal1 material=Aluminum y=[ -70.000, -27.650] z=[ 1.100, 2.100]\n", + "INFO metal1 material=Aluminum y=[ -10.350, 10.000] z=[ 1.100, 2.100]\n", + "INFO metal1 material=Aluminum y=[ 30.000, 70.000] z=[ 1.100, 2.100]\n", + "INFO pp_slab_0 material=pp_slab_0 y=[ -19.800, -17.800] z=[ 0.000, 0.090]\n", + "INFO pp_slab_1 material=pp_slab_1 y=[ -17.800, -15.800] z=[ 0.000, 0.090]\n", + "INFO npp_slab_0 material=npp_slab_0 y=[ -22.200, -20.200] z=[ 0.000, 0.090]\n", + "INFO npp_slab_1 material=npp_slab_1 y=[ -24.200, -22.200] z=[ 0.000, 0.090]\n", + "INFO p_rib material=p_rib y=[ -20.000, -19.800] z=[ 0.000, 0.220]\n", + "INFO n_rib material=n_rib y=[ -20.200, -20.000] z=[ 0.000, 0.220]\n" + ] + } + ], "source": [ "# Make the helper's diagnostics visible\n", "import logging\n", @@ -233,6 +302,175 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "3a9abd45", + "metadata": {}, + "source": [ + "## Optical-only cross-section\n", + "\n", + "The optical analysis uses a simplified component: the same rib + slab + PN\n", + "junction (identical \"doping profile\" polygons) but **no** electrodes, vias, or\n", + "graded doping. Every device region maps to plain silicon, so the optical mode\n", + "sees one homogeneous Si body embedded in a uniform SiO2 cladding.\n", + "\n", + "`gsim.common.cross_section.build_optical_cross_section()` assembles the\n", + "minimal all-dielectric `LayerStack` and extracts the 2D cross-section at $x=0$." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7474139a", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO Stack: optical\n", + "INFO Layers: ['core', 'n_rib', 'p_rib', 'slab']\n", + "INFO Device material: si\n", + "INFO Cladding material: sio2\n", + "INFO Dielectrics: [{'name': 'oxide', 'zmin': -2.0, 'zmax': 3.0, 'material': 'sio2'}]\n", + "INFO \n", + "INFO Cross-section x=0.0 intersects 4 layer regions:\n", + "INFO core material=si y=[ -20.200, -19.800] z=[ 0.000, 0.220]\n", + "INFO slab material=si y=[ -50.200, 50.200] z=[ 0.000, 0.090]\n", + "INFO p_rib material=si y=[ -20.000, -19.800] z=[ 0.000, 0.220]\n", + "INFO n_rib material=si y=[ -20.200, -20.000] z=[ 0.000, 0.220]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Optical stack layers: ['core', 'n_rib', 'p_rib', 'slab']\n", + "Optical cladding: [{'name': 'oxide', 'zmin': -2.0, 'zmax': 3.0, 'material': 'sio2'}]\n" + ] + } + ], + "source": [ + "# Uniform SiO2 cladding height above z=0 (um)\n", + "OPT_CLAD_TOP = 3.0\n", + "\n", + "stack_opt, section_opt = build_optical_cross_section(\n", + " comp_optical,\n", + " axis=CROSS_SECTION_AXIS,\n", + " value=CROSS_SECTION_VALUE,\n", + " device_layers={\n", + " \"core\": (LAYER.WG, 0.0, RIB_HEIGHT),\n", + " \"slab\": (LAYER.SLAB90, 0.0, SLAB_THICKNESS),\n", + " \"p_rib\": (LAYER.P, 0.0, RIB_HEIGHT),\n", + " \"n_rib\": (LAYER.N, 0.0, RIB_HEIGHT),\n", + " },\n", + " substrate_thickness=BOX_THICKNESS,\n", + " cladding_top=OPT_CLAD_TOP,\n", + ")\n", + "\n", + "print(\"Optical stack layers:\", sorted(stack_opt.layers.keys()))\n", + "print(\"Optical cladding:\", stack_opt.dielectrics)" + ] + }, + { + "cell_type": "markdown", + "id": "71bcea16", + "metadata": {}, + "source": [ + "### Optical cross-section plot\n", + "\n", + "The PN junction still shows its P/N profile, but both regions and the slab are\n", + "the same silicon material." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "518d76a7", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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nvXfeustmUwmDRs2TCdPntSECRPUpk0bbdu2TcWKFbvp9grDyJEjNWbMGMvrJzU1VZs3b9bvv/+uBx54INflJk2apPDwcHXq1ElFixbVokWL9NxzzykzM1MDBgywqvvXX3+pa9eu6t+/v2JjYzV16lR16dJFS5cuzXMbt8PVq1d1+PBhlS5dOttzgwYN0vjx4zVy5Eib73bfivXr1+u7777Tc889pxIlSuiDDz7QY489pkOHDlniPXbsmBo0aKDk5GT17dtXVatW1dGjRzV37lxdvHhRzZs318CBA/XBBx/o5ZdfVrVq1STJ8u+NLl26pKioKCUkJOj5559XaGio5syZo7i4OCUnJ1u9V0nSzJkzdf78efXr108mk0nvvvuuHn30Uf3999953oXcsmWL0tPTVadOnWzPFeTaskWFChW0YsUKuzcVrlChglauXKkjR46ofPnyBVr2Vj4vr2cYhk6cOKHw8PCbbvNm78m5iY+Pl6enp4YMGSJPT0/99NNPev3115Wamqpx48ZZ6n3++efq16+fGjdurMGDB+vvv/9Wp06d5OPjo6CgIEu9S5cuqXXr1jp06JAGDhyowMBATZ8+XT/99NNN9yFLfq7LxYsXW1oCjBkzRufOnVOvXr1ybXlRt25djR8/Xrt27borm+oDkiQDgFNLTk42JBkPP/xwnvU6depkSDJSU1MNwzCMESNGGJKMTp06WdV77rnnDEnGH3/8YSkrXry4ERsbm22dU6dONSQZiYmJllhKlChhNGzY0Lh06ZJV3czMTMv/L168mG1dY8aMMUwmk3Hw4EFLWVaMebl27ZoRGhpqVKhQwTh37lyu24yNjTUkGS+99JJVnfnz5xuSjDfffNOq/PHHHzdMJpORkJBgGIZhjB8/3pBknDp1KtdYHn74YSM8PDzPeHNy7tw5Q5Ixbty4XOucP3/eKFmypNGnTx+r8qSkJMPb29uqvHnz5kaJEiWsjqVhWB+PcePGWZ2761WoUMHqfA8ePNiQZKxbt84qntDQUCMkJMTIyMgwDMMwVq1aZUgyqlWrZly5csVS93//+58hydixY4elLKvuiBEjct3nLC1atDAkGZMnT87xuRYtWmRbb7ly5SzXumEYxjfffGNIMv73v//ddHs5bSOn6/9matWqZXTo0CHPOjld4zm9PqKjo42KFStalVWoUMGQZHz77beWspSUFCMgIMCoXbt2gePNej3bokKFCkbbtm2NU6dOGadOnTL++OMPo1u3boYk44UXXrDUa9GiheU1MmrUKEOSsWXLFsMwDCMxMfGmr4MsN147sbGxRoUKFbLVy+n4SjLc3Nwsr23DMIw//vjDkGRMnDjRUhYTE2O4uLgYv/32W7b1Zr2W5syZY0gyVq1ala3OjdfmhAkTDEnGV199ZSlLT083GjVqZHh6elqu16zjULp0aePs2bOWugsWLDAkGYsWLcr5oPx/n332WbbXW5b8Xls3xp4V09SpU/Pc9s6dO41ixYoZkozIyEhj0KBBxvz58420tLRsdW/cRkF8/vnnlvPYsmVL47XXXjPWrVtneS+63vXXiq2flzmZPn26Icn4/PPPbxpvft6Tb/w8NYycz1e/fv0MDw8P4/Lly4Zh/HMNlS1b1oiMjLR63/30008NSTleg998842lLC0tzahUqVK26/jG11RBrsuaNWsa5cuXN86fP28pW716tSEpx9fpxo0bDUnG7Nmzsz0H3C1oXg44ufPnz0v6p8lmXrKeT01NtSq/8e7GCy+8IEk2TeGxfPlynT9/Xi+99FK2fnTXN/G8/k5jWlqaTp8+rcaNG8swDG3durVA29y6dasSExM1ePDgbAOC5TQV07PPPmv1eMmSJSpSpIgGDhxoVT506FAZhqEffvhBkizrXrBgQa6Dy5QsWVJHjhzJVxPQ6xUrVkxubm5avXq1zp07l2Od5cuXKzk5WU8++aROnz5t+StSpIgaNmyoVatWSZJOnTqltWvX6plnnlFwcLDVOmydmmrJkiVq0KCBmjZtainz9PRU3759deDAAe3evduqfs+ePa2aYmbdUfr7778tZVFRUTIMI99T+ZjNZvXs2TPfMcfExFi9Jh5//HEFBATc9Lq+evWq1fE9ffq0rl69qitXrmQrv9kgQyVLltSuXbv0119/5Ttuyfr1kZKSotOnT6tFixb6+++/lZKSYlU3MDDQqqWBl5eXYmJitHXrViUlJeW5nXPnzlntT9bUUjfu58WLF/MV97Jly+Tr6ytfX1/VqlVLc+bMUY8ePTR27Ngc62cNgHX94Fq3S5s2baxa7ERERMjLy8tyjWZmZmr+/Pnq2LGj6tWrl215W15LS5Yskb+/v5588klLmaurqwYOHKgLFy5ozZo1VvW7du2qUqVKWR7n9DrKyZkzZyTJatksBbm2bBEeHq5t27bp6aef1oEDB/S///1PnTt3lp+fn6ZMmXLL68/yzDPPaOnSpYqKitL69ev1xhtvqFmzZqpcubI2btyY63K3+nmZZe/evRowYIAaNWqk2NjYPNd1K+/J15+v8+fP6/Tp02rWrJmle44kbd68WSdPnlT//v2t3nfj4uLk7e1ttb4lS5YoICBAjz/+uKXMw8OjQAPu3ey6PHbsmHbs2KGYmBirkfNbtGihmjVr5rjOrPXlp7sR4KxIugEnl/XlIOvLRG5y+7JRuXJlq8dhYWFycXGxaQqc/fv3S9JNm4cdOnRIcXFx8vHxkaenp3x9fdWiRQtJKvAXv/xuU5KKFi2arSniwYMHFRgYmO24ZDURPXjwoKR/vmg0adJEvXv3lp+fn7p166ZvvvnGKvEaNmyYPD091aBBA1WuXFkDBgywan6enp6upKQkq7+MjAyZzWaNHTtWP/zwg/z8/NS8eXO9++67VklTVuLWqlUrS2KT9bds2TLL4EFZX3wKs4newYMHVaVKlWzlNx6jLDd+scz6QpXbDwr5Ua5cuQINvHTjdW0ymVSpUqWbXtcbNmzIdnw3btyoWbNmZSs/dOhQnusaPXq0kpOTdd9996lmzZp68cUXtX379pvGvmHDBrVp00bFixdXyZIl5evra+nDfuPro1KlStm+uN93332SdNN9rV27ttX+ZP3gduN+Xt+3NS8NGzbU8uXLtWLFCm3cuFGnT5/WtGnTcm3O7+3trcGDB2vhwoUF/rHtVt14jUr/XKdZ1+ipU6eUmppa6K+jypUrZ5sZwF6vIyOH/rEFubZsdd9992n69Ok6ffq0tm/frrfffltFixZV3759tWLFikLZhiRFR0frxx9/VHJystauXasBAwbo4MGDeuihh7INppblVj8vpX/G3+jQoYO8vb01d+7cmw4meivvybt27dIjjzwib29veXl5ydfX1zJYYdb5yrpubnzPc3V1zTZw6sGDB3N8z8jp/T03N7sus+KpVKlStmVzKpP+71q19YdhwBnQpxtwct7e3goICLjpl/nt27erXLly8vLyyrOevT/0MjIy9MADD+js2bMaNmyYqlatquLFi+vo0aOKi4uz6xQlZrPZ5qmwihUrprVr12rVqlVavHixli5dqtmzZ6tVq1ZatmyZihQpomrVqmnfvn36/vvv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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from gsim.palace import plot_plane_section\n", + "\n", + "PLOT_ZOOM_OPT = {\"h_range\": (-4.0, 4.0), \"v_range\": (-0.6, 1.5)}\n", + "PLOT_COLORS_OPT = {\n", + " \"core\": \"#c0392b\", # rib Si\n", + " \"slab\": \"#e67e22\", # slab Si\n", + " \"p_rib\": \"#2980b9\", # PN junction P region (Si)\n", + " \"n_rib\": \"#27ae60\", # PN junction N region (Si)\n", + "}\n", + "PLOT_TITLE_OPT = (\n", + " \"Optical cross-section: rib + slab + PN junction (all Si, SiO2 cladding)\"\n", + ")\n", + "\n", + "plot_plane_section(\n", + " section_opt,\n", + " colors=PLOT_COLORS_OPT,\n", + " h_range=PLOT_ZOOM_OPT[\"h_range\"],\n", + " v_range=PLOT_ZOOM_OPT[\"v_range\"],\n", + " title=PLOT_TITLE_OPT,\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "9a8a7a82", + "metadata": {}, + "source": [ + "### Optical material properties (1550 nm)\n", + "\n", + "The PDK stack materials resolve to their Sellmeier optical constants at the\n", + "simulation wavelength. The Palace boundary-mode config generator evaluates\n", + "dispersion at `F_OPT` automatically; the values below are for reference." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "22caa2d7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "si @ 1.55 um: eps=12.0946 n=3.4777\n", + "sio2 @ 1.55 um: eps=2.0852 n=1.4440\n" + ] + } + ], + "source": [ + "import math\n", + "\n", + "from gsim.common.stack.materials import resolve_material_at_wavelength\n", + "\n", + "LAMBDA_OPT = 1.55 # reference wavelength (um)\n", + "\n", + "si_opt = resolve_material_at_wavelength(\"si\", LAMBDA_OPT)\n", + "sio2_opt = resolve_material_at_wavelength(\"sio2\", LAMBDA_OPT)\n", + "print(\n", + " f\"si @ {LAMBDA_OPT:.2f} um: eps={si_opt.permittivity_scalar:.4f} n={math.sqrt(si_opt.permittivity_scalar):.4f}\"\n", + ")\n", + "print(\n", + " f\"sio2 @ {LAMBDA_OPT:.2f} um: eps={sio2_opt.permittivity_scalar:.4f} n={math.sqrt(sio2_opt.permittivity_scalar):.4f}\"\n", + ")" + ] + }, { "cell_type": "markdown", "id": "7", @@ -247,7 +485,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "8", "metadata": {}, "outputs": [], @@ -275,10 +513,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "9", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "image/png": "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J09gYmJS4fY0mni/7smTJ7Czs8OXX36JYcOGlVo/IyMDJiYmSE9PZ+JNREREREREKqHqXLPik9VVyNTUFE2aNMHNmzc1HQoRERERERGRSlSpxDszMxO3bt1C/fr1NR0KERERERERkUpo9OZqU6dORffu3WFnZ4cHDx5gzpw50NbWxoABAzQZFhERERERkcrl5+fjxYsXmg6DXqOrqwttbW219qHRxPvevXsYMGAAUlJSYG5uDn9/f0RFRcHc3FyTYREREREREalUZmYm7t27hyp0iy36/yQSCWxsbGBoaKi2PjSaeG/dulWT3RMREREREaldfn4+7t27h1q1asHc3BwSiUTTIdH/J4TAo0ePcO/ePTRu3FhtI98aX8ebiIiIiIioJnvx4gWEEDA3N4eBgYGmw6HXmJubIz4+Hi9evFBb4l2lbq5GRERERERUU3Gku2qqjPeFiTcRERERERGRGnGqORERERERUSX7ZOwYpN1LUHm7dWxs8fmatSpvlyqGiTcREREREVElS7uXgFFmtVTe7no1JPNFycvLg44O00llcao5ERERERHRG+js2bPw9/dHixYt4Obmhl9//RXR0dHw9fWFm5sbWrdujTNnzgAA4uPjYWpqik8++QQtW7bE6tWrkZiYiL59+6J169ZwdXXFrFmzNLxHVRd/oiAiIiIiInrDpKamomfPntixYwfatm2LgoICPH78GF5eXvjuu+8QFBSE06dPo3fv3rh58yYAID09Hc7Ozvj8888BAEFBQZgxYwbatWuHvLw8vPvuu9i+fTv69OmjyV2rkph4ExERERERvWHOnj0LJycntG3bFgCgpaWFpKQkaGlpISgoCADg7+8PS0tLxMTEwMbGBrq6uhg8eDAA4NmzZzh69CiSkpJkbWZmZuLGjRuVvzPVABNvIiIiIiIiKtKrS23VqlULWlovr1YWQgAAoqKioK+vr5HYqhNe401ERERERPSG8fX1RWxsLE6dOgUAKCgogKWlJQoKCnD48GEAwJ9//onExES4u7srvN7Q0BAdOnTAkiVLZNsePHiAe/fuVUr81Q1HvImIiIiIiCpZHRtbtdyBvI6NrXL16tTB7t27MWXKFDx9+hRaWlpYsGABdu3ahYkTJ2LKlCnQ19fHjh07YGhoiMePHyu0sWnTJkyePBkuLi6QSCSoXbs21q9fDxsbG1XvVrUnEYVzBKqhjIwMmJiYID09HcbGxpoOh4iIiIiISEF2djbi4uLg4ODAadlVUFHvj6pzTU41JyIiIiIiIlIjJt5EREREREREasTEm4iIiIiIiEiNmHgTERERERERqRETbyIiIiIiIiI1YuJNREREREREpEZcx5uIiIiIiKiSzfxoNDKS76q8XWOLBli0Yp3K26WKYeJNRERERERUyTKS72JOgK7K2513svzJfGRkJCZNmoSYmJgK1Vu2bBk2bNiAgoICODk5ITw8HKampgCAc+fOYeTIkcjKyoKNjQ1+/vlnvPXWW+WOubqoMlPNlyxZAolEgkmTJmk6FCIiIiIiIiqHw4cPIzw8HGfPnsU///wDT09PzJw5EwBQUFCAQYMGYeXKlfjvv//wzjvvvDH5X5VIvM+fP4/169fDzc1N06EQERERERHVeFlZWejXrx+aN2+OFi1aoHPnznLleXl5CAoKgpeXF5ydnTFw4EA8e/ZMrjw4OBguLi7w9PSUjX5funQJ/v7+MDIyAgC88847+PnnnwEAFy5cgI6ODjp06AAAGDVqFH7//XdkZ2dXwh5rlsYT78zMTAwaNAjfffcd6tSpU2LdnJwcZGRkyD2IiIiIiIiobA4cOIAnT57gn3/+waVLl7B161a5cm1tbWzevBnR0dG4evUqTExM8PXXX8vKr127hpCQEFy9ehWffPIJ+vfvDyEEPD09ceTIESQmJkIIgU2bNuHp06dITU3F3bt3YWdnJ2vDyMgIxsbGePDgQaXtt6ZoPPEeN24cunXrhsDAwFLrhoWFwcTERPawtbWthAiJiIiIiIhqlhYtWuDff//F2LFj8csvv0BXV/56cyEEVqxYAQ8PD7i5uWHv3r1y13Tb29ujU6dOAIC+ffsiMTERCQkJ6NChA6ZOnYp3330Xbdq0gbm5OQBAR+fNvr2YRhPvrVu34u+//0ZYWJhS9adPn4709HTZIyEhQc0REhERERER1TwNGzbEP//8gy5duuDMmTNwcXFBWlqarHzz5s04duwYTpw4gStXrmDq1KklTgmXSCSQSCQAgLFjxyI6Ohrnzp1D+/btYWNjA2NjYzRo0AB37tyRvebp06dIT0+HtbW1+na0itBY4p2QkIAPP/wQmzZtgr6+vlKvkUqlMDY2lnsQERERERFR2dy7dw8SiQTvvfcevvjiCwgh5AY209LSYGZmBmNjYzx9+hQRERFyr4+Pj8fx48cBADt27IClpSVsbGwAAA8fPgQAPH/+HLNnz8bHH38MAPD09MSLFy9kr1u/fj26d++udD5YnWlsvP/ChQtITk5Gy5YtZdvy8/Nx8uRJrF69Gjk5OdDW1tZUeERERERERGpjbNGgQkt/ldSuMq5cuYLp06dDCIG8vDwMGTJE7mbXwcHB+PXXX+Hk5ARzc3O0bdtWbrTa2dkZERERmDhxIvT09LBlyxbZiHfnzp1RUFCA3NxcDBkyBOPHjwcAaGlpYePGjRg1ahSys7NhbW0tu/FaTScRQghNdPz06VO5Nw4Ahg4diqZNm+KTTz6Bi4tLqW1kZGTAxMQE6enpHP0mIiIiIqIqKTs7G3FxcXBwcHgjRnerm6LeH1Xnmhob8TYyMlJIrmvXro169eoplXQTERERERERVQcav6s5ERERERERUU1Wpe7pHhkZqekQiIiIiIiIiFSKI95EREREREREasTEm4iIiIiIiEiNmHgTERERERERqVGVusabiIiIiIjoTTDr05F4mnan9IplZFTHDguXfKvydqlimHgTERERERFVsqdpd7BoTI7K2525tmLJ/G+//Ybjx49jxYoVKoqoYiIiItCmTRs0bdq03G3ExMTg+vXr6N+/vwojKxtONSciIiIiIiIAwHvvvVdlkm7gZeJ9/fr1CrURExODrVu3qiii8mHiTURERERE9IZZtGgRxo8fL3uemZmJunXrYtmyZejZsycAIDExER06dICnpyecnZ0xfvx4FBQUlNju/fv38f7778PV1RVubm747LPPAADJycno1asXXF1d4eLigvXr18teY29vj9mzZ8PHxwcODg5YuHAhAOD7779HdHQ0PvroI7i7u2Pfvn24cuUK/P390bJlSzRv3lxWFwByc3Mxbdo0uLi4oEWLFujSpQuSk5Mxe/ZsHD9+HO7u7hg9erSqDmGZcKo5ERERERHRGyY4OBienp5Yvnw5pFIptm/fjg4dOsDc3FxWx9TUFL///jsMDQ2Rn5+PHj16YNu2bSVO2R48eDA6d+6MHTt2AAAePXoEAJgwYQKcnJywa9cuJCcnw9PTEy1atECbNm0AAE+ePMHZs2fx+PFjODo6YujQoRg+fDg2btyISZMmyX4MePr0KY4ePQqpVIqsrCz4+voiMDAQbdq0QVhYGP777z9cuHABUqkUjx49grm5OebPn489e/Zgz5496jmYSuCINxERERER0RvG1tYWHh4e+O233wC8nNI9dOhQuToFBQX45JNP0KJFC3h4eCA6OhoxMTHFtpmZmYnTp09jypQpsm2FifyRI0cwatQoAICFhQV69eqFI0eOyOoNHDgQAGBmZoaGDRsiLi6uyD6ysrIwfPhwuLq6ok2bNrhz544spj/++AMffvghpFKpXN9VARNvIiIiIiKiN9AHH3yA8PBw3L59Gzdv3kSXLl3kyr/88kskJyfj3LlzuHz5MgYOHIjs7GyV9C2RSOSe6+vry/6tra2NvLy8Il83Y8YMmJmZ4eLFi7h06RLat2+vspjUiYk3ERERERHRG6hnz544f/48wsLCMHjwYOjoyF+JnJaWBisrK+jr6yMxMRHbt28vsT1DQ0MEBARg+fLlsm2FU80DAwPx3Xffybbt2rULb7/9dqkxGhsbIz09XS4mGxsb6Ojo4MaNGzh8+LCs7L333sNXX32FnJwcub5fb0MTeI03ERERERFRJTOqY1fhpb+Ka1dZUqkUffv2xZo1a/Dvv/8qlH/44Yd4//334ezsDGtrawQGBpba5s8//4wJEybA2dkZurq66NGjB+bNm4dVq1ZhzJgxcHV1hRACM2fOhLe3d6ntjRw5ElOmTMGKFSuwePFizJo1C0OGDMGGDRvg6OiIjh07yup+8sknmDlzJlq2bAldXV1YW1tj37596NSpE7744gu4ubnB19cX69atU/oYqYpECCEqvVcVycjIgImJCdLT02FsbKzpcIiIiIiIiBRkZ2cjLi4ODg4OclOqqWoo6v1Rda7JqeZEREREREREasSp5kRERERERKS077//HqtXr1bY/vXXX6Nt27YaiKjqY+JNREREREREShs+fDiGDx+u6TCqFU41JyIiIiIiIlIjJt5EREREREREasTEm4iIiIiIiEiNNHqN99q1a7F27VrEx8cDAJydnTF79mx07dpVk2ERERERERGp1UeTJyP50SOVt2thbo4VX36p8napYjSaeNvY2GDJkiVo3LgxhBDYsGEDevTogYsXL8LZ2VmToREREREREalN8qNHCAjqovJ2Tx48oPI2ixMfH48DBw5g9OjRStWXSCRIS0uDqakpFi9ejA0bNiA2Nha7du1Cz5491Rushml0qnn37t3xzjvvoHHjxmjSpAkW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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "import matplotlib.pyplot as plt\n", "\n", @@ -308,7 +557,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "11", "metadata": {}, "outputs": [], @@ -351,10 +600,47 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "12", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO Generating mesh in palace-sim-mzm-pn\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO Extracting geometry...\n", + "INFO Polygons: 17\n", + "INFO Bbox: (-5.0, -70.0, 10.0, 70.0)\n", + "INFO Building native 2D BoundaryMode geometry...\n", + "INFO Filtered 14 internal conductor curves for 'metal1' (2 remaining)\n", + "INFO Filtered 6 internal conductor curves for 'via_contact' (2 remaining)\n", + "INFO Generating native 2D BoundaryMode mesh...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2D domain groups: ['n_rib', 'npp_slab_0', 'npp_slab_1', 'p_rib', 'passive', 'pp_slab_0', 'pp_slab_1', 'sio2', 'slab90', 'air']\n", + " Bounding box: 340.000 x 208.600 x 0.000 um\n", + " Nodes: 42,253\n", + " Elements: 103,726\n", + " Domain groups: 10\n", + " Conductor surfaces:2\n", + " Interface surfaces:17\n", + " Est. ND-space DOFs (order 2): ~622,356\n", + " Est. H1-space DOFs (order 2): ~622,356\n", + " Est. total DOFs: ~1,244,712\n" + ] + } + ], "source": [ "from gsim.palace import BoundaryModeSim\n", "\n", @@ -389,7 +675,68 @@ "execution_count": null, "id": "13", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO Solid plot: using 1 surface cell blocks\n", + "INFO awaiting runner setup\n", + "INFO awaiting site startup\n", + "INFO Print WSLINK_READY_MSG\n", + "INFO Schedule auto shutdown with timeout 0\n", + "INFO awaiting running future\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "b63ec5af348f44efb65c940d2aee74a1", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Widget(value='