From 69509c85477ea12b73ddb9664c089933fa872e1b Mon Sep 17 00:00:00 2001 From: mdmaas Date: Wed, 19 Aug 2026 21:02:54 -0300 Subject: [PATCH 01/14] incorporate palace binary downloader --- README.md | 8 +- pyproject.toml | 2 + src/gsim/palace/__init__.py | 9 +- src/gsim/palace/base.py | 20 ++- src/gsim/palace/runtime.py | 289 ++++++++++++++++++++++++++++--- tests/palace/test_sim_classes.py | 242 ++++++++++++++++++++++---- 6 files changed, 504 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index ed4edd5c..6bbc1697 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,9 @@ with minimal boilerplate. pip install gsim ``` -For local Palace execution on Linux x86_64, install the optional prebuilt binary separately: - -```bash -pip install "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" -``` +For local Palace execution on Linux x86_64, gsim can auto-download and cache a prebuilt Palace CPU binary on first use. +No additional package installation is required. If you prefer to supply a binary yourself, set `PALACE_BIN` (path to an +executable) or put `palace` on your `PATH`; a Palace SIF image may be given via `PALACE_SIF` for Apptainer-based runs. For development (requires [uv](https://docs.astral.sh/uv/)): diff --git a/pyproject.toml b/pyproject.toml index 9d18d06c..5e16c5a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,6 +275,8 @@ select = ["ALL"] ] "tests/**/*.py" = [ "ANN", # flake8-annotations + "ARG001", # unused-function-argument + "ARG005", # unused-lambda-argument (test stubs) "D", # pydocstyle "INP001", # implicit-namespace-package "PLC0415", # allow imports inside tests diff --git a/src/gsim/palace/__init__.py b/src/gsim/palace/__init__.py index 836c67cd..b155a643 100644 --- a/src/gsim/palace/__init__.py +++ b/src/gsim/palace/__init__.py @@ -136,8 +136,12 @@ load_sparams, ) -# Runtime / binary resolution (optional palace-toolkit-cpu dependency) -from gsim.palace.runtime import resolve_palace_binary, resolve_palace_library_dir +# Runtime / binary resolution (self-contained; can auto-download a Palace CPU runtime) +from gsim.palace.runtime import ( + install_palace_runtime, + resolve_palace_binary, + resolve_palace_library_dir, +) from gsim.viz import ( close_interactive_view, close_interactive_views, @@ -206,6 +210,7 @@ "get_material_properties", "get_port_map", "get_stack", + "install_palace_runtime", "interactive_mode", "load_boundary_field_data", "load_field_context", diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 0da686a0..b7151138 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -2115,13 +2115,23 @@ def run_local( resolved_exe = bundled lib_dir = resolve_palace_library_dir() if verbose: + from gsim.palace.runtime import ( + _cached_binary as _cached, + ) from gsim.palace.runtime import ( _palace_cpu_available as _cpu_avail, ) + from gsim.palace.runtime import ( + _palace_toolkit_available as _toolkit_avail, + ) source = ( "palace-toolkit-cpu" if _cpu_avail() + else "gsim cached runtime" + if _cached() is not None + else "palace-toolkit" + if _toolkit_avail() else "PALACE_BIN / PATH" ) logger.info( @@ -2149,9 +2159,9 @@ def run_local( if resolved_exe is None: raise FileNotFoundError( - "Palace executable not found. Set PALACE_BIN, " - "PALACE_EXECUTABLE, or install the optional " - "palacetoolkit-palace-cpu wheel documented in the gsim README." + "Palace executable not found. Set PALACE_BIN or " + "PALACE_EXECUTABLE, install a Palace binary, or let gsim " + "auto-download the prebuilt CPU runtime (Linux x86_64)." ) exe_path = Path(resolved_exe) @@ -2163,8 +2173,8 @@ def run_local( raise FileNotFoundError( f"Palace executable not found: {exe_path}. " "Install Palace directly or provide correct path via " - "palace_executable, or install the optional " - "palacetoolkit-palace-cpu wheel documented in the gsim README." + "palace_executable, or let gsim auto-download the " + "prebuilt CPU runtime (Linux x86_64)." ) exe_path = Path(resolved) diff --git a/src/gsim/palace/runtime.py b/src/gsim/palace/runtime.py index c3f51ef4..47195eab 100644 --- a/src/gsim/palace/runtime.py +++ b/src/gsim/palace/runtime.py @@ -1,38 +1,218 @@ """Palace runtime/binary resolution. -Provides a unified resolver for locating a Palace executable, with -optional delegation to the ``palacetoolkit_palace_cpu`` package (the -``palace-toolkit-cpu`` distribution) when installed. +Provides a unified resolver for locating a Palace executable, plus a +self-contained installer that downloads and caches a prebuilt Palace CPU +binary. gsim absorbs this functionality so users do **not** need to install +any direct-URL wheel or third-party runtime package to run Palace locally on +Linux x86_64. Resolution order ----------------- 1. ``PALACE_BIN`` environment variable. 2. ``PALACE_EXECUTABLE`` environment variable, or ``"palace"`` in ``PATH``. -3. ``palacetoolkit_palace_cpu`` packaged binary (when the optional - ``palace-toolkit-cpu`` extra is installed). -4. ``None`` if nothing was found. +3. ``palacetoolkit_palace_cpu`` packaged binary (if the legacy + ``palace-toolkit-cpu`` wheel happens to be installed). +4. gsim's own cached/downloaded Palace CPU runtime (Linux x86_64). +5. Delegation to ``palacetoolkit`` (if the ``palace-toolkit`` distribution + happens to be installed). +6. ``None`` if nothing was found. + +The auto-download is only attempted when ``PALACETOOLKIT_AUTO_DOWNLOAD_BINARY`` +is not disabled, and only on Linux x86_64 (the platform the prebuilt Palace CPU +wheel is provided for). """ from __future__ import annotations import importlib.util +import json import logging import os +import platform import shutil +import stat import subprocess +import tempfile +from contextlib import suppress from pathlib import Path +from urllib.request import Request, urlopen +from zipfile import ZipFile logger = logging.getLogger(__name__) +_DEFAULT_BINARY_TAG = "0.17.0" +_AUTO_DOWNLOAD_ENV = "PALACETOOLKIT_AUTO_DOWNLOAD_BINARY" +_TAG_ENV = "PALACETOOLKIT_PALACE_CPU_TAG" +_CACHE_ENV = "PALACETOOLKIT_RUNTIME_DIR" + + +def _is_linux_x86_64() -> bool: + """Return whether the current platform is Linux on x86_64. + + The prebuilt Palace CPU runtime is only provided for this platform. + """ + return platform.system() == "Linux" and platform.machine() == "x86_64" + + +def _runtime_cache_dir() -> Path: + """Return the directory used to cache downloaded Palace runtimes.""" + root = os.environ.get(_CACHE_ENV, "").strip() + if root: + return Path(root).expanduser().resolve() + return (Path.home() / ".cache" / "palacetoolkit" / "runtime").resolve() + + +def _binary_tag() -> str: + """Return the Palace CPU runtime version tag to download.""" + return os.environ.get(_TAG_ENV, _DEFAULT_BINARY_TAG).strip() or _DEFAULT_BINARY_TAG + + +def _binary_wheel_url(tag: str) -> str: + """Return the GitHub release URL for the given Palace CPU runtime tag.""" + return ( + "https://github.com/EpsilonForge/PalaceToolkit/releases/download/" + f"palace-cpu-v{tag}/" + f"palacetoolkit_palace_cpu-{tag}-py3-none-linux_x86_64.whl" + ) + + +def _binary_wheel_url_from_release(tag: str, timeout: float) -> str | None: + """Discover the current wheel URL from the GitHub release API (best-effort).""" + api_url = ( + "https://api.github.com/repos/EpsilonForge/PalaceToolkit/releases/tags/" + f"palace-cpu-v{tag}" + ) + request = Request( # noqa: S310 + api_url, headers={"Accept": "application/vnd.github+json"} + ) + with urlopen(request, timeout=timeout) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + + for asset in payload.get("assets", []): + name = str(asset.get("name", "")) + if name.endswith("linux_x86_64.whl") and "palacetoolkit_palace_cpu-" in name: + url = str(asset.get("browser_download_url", "")) + if url: + return url + return None + + +def _cached_runtime_prefix(tag: str | None = None) -> Path: + """Return the cache directory for a specific runtime tag.""" + resolved_tag = tag or _binary_tag() + return _runtime_cache_dir() / f"palace-cpu-v{resolved_tag}" + + +def _set_executable(path: Path) -> None: + """Make the given path executable for all users.""" + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def install_palace_runtime(force: bool = False, timeout: float = 180.0) -> Path: + """Download and cache the prebuilt Palace CPU runtime. + + Returns: + Path to the cached ``palace`` launcher executable. + + Raises: + RuntimeError: If the platform is unsupported or the download/install + fails. + """ + if not _is_linux_x86_64(): + raise RuntimeError( + "Prebuilt runtime download is only supported on Linux x86_64" + ) + + tag = _binary_tag() + prefix = _cached_runtime_prefix(tag) + bin_palace = prefix / "bin" / "palace" + lib_dir = prefix / "lib" + if not force and bin_palace.is_file() and lib_dir.is_dir(): + return bin_palace + + prefix.mkdir(parents=True, exist_ok=True) + downloads = _runtime_cache_dir() / "downloads" + downloads.mkdir(parents=True, exist_ok=True) + wheel_name = f"palacetoolkit_palace_cpu-{tag}-py3-none-linux_x86_64.whl" + wheel_path = downloads / wheel_name + + if force or not wheel_path.is_file(): + url = _binary_wheel_url(tag) + with suppress(Exception): + discovered = _binary_wheel_url_from_release(tag, timeout=timeout) + if discovered: + url = discovered + with urlopen(url, timeout=timeout) as response: # noqa: S310 + wheel_path.write_bytes(response.read()) + + with tempfile.TemporaryDirectory( + prefix="palace-runtime-", dir=_runtime_cache_dir() + ) as tmp: + tmp_path = Path(tmp) + with ZipFile(wheel_path, "r") as wheel_zip: + wheel_zip.extractall(tmp_path) + + payload_root = tmp_path / "palacetoolkit_palace_cpu" + if not payload_root.is_dir(): + raise RuntimeError( + "Downloaded wheel does not contain palacetoolkit_palace_cpu payload" + ) + + bin_src = payload_root / "bin" + lib_src = payload_root / "lib" + if not bin_src.is_dir() or not lib_src.is_dir(): + raise RuntimeError( + "Downloaded wheel is missing expected bin/lib runtime directories" + ) + + if prefix.exists(): + shutil.rmtree(prefix) + prefix.mkdir(parents=True, exist_ok=True) + shutil.copytree(bin_src, prefix / "bin") + shutil.copytree(lib_src, prefix / "lib") + + if not bin_palace.is_file(): + raise RuntimeError("Cached runtime install did not produce bin/palace") + _set_executable(bin_palace) + bin_native = prefix / "bin" / "palace-x86_64.bin" + if bin_native.is_file(): + _set_executable(bin_native) + return bin_palace + + +def _cached_binary() -> Path | None: + """Return the cached ``palace`` launcher path, or ``None`` if not present.""" + candidate = _cached_runtime_prefix() / "bin" / "palace" + return candidate if candidate.is_file() else None + + +def _cached_library_dir() -> Path | None: + """Return the cached runtime ``lib`` directory, or ``None`` if not present.""" + candidate = _cached_runtime_prefix() / "lib" + return candidate if candidate.is_dir() else None + + +def _auto_download_enabled() -> bool: + """Return whether auto-download of the Palace runtime is enabled.""" + raw = os.environ.get(_AUTO_DOWNLOAD_ENV, "1").strip().lower() + return raw not in {"0", "false", "no", "off"} + def _palace_cpu_available() -> bool: - """Check whether the optional ``palacetoolkit_palace_cpu`` package is installed.""" + """Check whether the legacy ``palacetoolkit_palace_cpu`` package is installed.""" return importlib.util.find_spec("palacetoolkit_palace_cpu") is not None +def _palace_toolkit_available() -> bool: + """Check whether the ``palacetoolkit`` package (``palace-toolkit``) is installed.""" + return importlib.util.find_spec("palacetoolkit") is not None + + def resolve_palace_binary( *, prefer_bundled: bool = False, + download_if_missing: bool = True, ) -> Path | None: """Return a path to a runnable Palace executable, or ``None``. @@ -40,9 +220,10 @@ def resolve_palace_binary( ---------- prefer_bundled: If ``True``, skip the ``PALACE_BIN`` / ``PALACE_EXECUTABLE`` / - ``PATH`` checks and go straight to the palace-toolkit-cpu bundled - binary (useful when the caller explicitly wants the bundled - runtime). + ``PATH`` checks and go straight to gsim's cached/bundled runtime. + download_if_missing: + If ``True`` (default), auto-download and cache a prebuilt Palace CPU + runtime on Linux x86_64 when no binary is found elsewhere. Returns: ------- @@ -74,7 +255,7 @@ def resolve_palace_binary( ) return Path(resolved).resolve() - # 3. Optional palace-toolkit-cpu bundled binary + # 3. Legacy palace-toolkit-cpu packaged binary if _palace_cpu_available(): from palacetoolkit_palace_cpu import palace_binary_path @@ -94,34 +275,101 @@ def resolve_palace_binary( "resolve_palace_binary: palace-toolkit-cpu not installed — skipping" ) + # 4. gsim's own cached runtime + cached = _cached_binary() + if cached is not None and _binary_is_runnable(cached, _cached_library_dir()): + logger.info( + "resolve_palace_binary: using gsim cached runtime %s", + cached, + ) + return cached.resolve() + + # 5. Auto-download a prebuilt Palace CPU runtime (Linux x86_64) + if download_if_missing and _is_linux_x86_64() and _auto_download_enabled(): + with suppress(Exception): + downloaded = install_palace_runtime(force=False) + if _binary_is_runnable(downloaded, _cached_library_dir()): + logger.info( + "resolve_palace_binary: using gsim downloaded runtime %s", + downloaded, + ) + return downloaded.resolve() + + # 6. Delegation to the palace-toolkit package (if installed) as a fallback + if _palace_toolkit_available(): + try: + from palacetoolkit.palace_runtime import ( + resolve_palace_binary as _ptk_resolve_binary, + ) + + candidate = _ptk_resolve_binary() + except Exception as exc: + logger.debug( + "resolve_palace_binary: palacetoolkit resolver failed: %s", exc + ) + candidate = None + if candidate is not None: + candidate = Path(candidate) + if candidate.is_file() and _binary_is_runnable(candidate): + logger.info( + "resolve_palace_binary: using palace-toolkit runtime %s", + candidate, + ) + return candidate.resolve() + return None def resolve_palace_library_dir() -> Path | None: """Return the Palace library directory (for ``LD_LIBRARY_PATH``). - Only available when ``palace-toolkit-cpu`` is installed and provides a - bundled ``lib/`` directory alongside its binary. - Returns: ------- Path | None """ - if not _palace_cpu_available(): - return None + if _palace_cpu_available(): + from palacetoolkit_palace_cpu import palace_library_path - from palacetoolkit_palace_cpu import palace_library_path + lib_dir = palace_library_path() + if lib_dir.is_dir(): + return lib_dir.resolve() - lib_dir = palace_library_path() - return lib_dir.resolve() if lib_dir.is_dir() else None + cached = _cached_library_dir() + if cached is not None: + return cached.resolve() + if _palace_toolkit_available(): + try: + from palacetoolkit.palace_runtime import ( + resolve_palace_library_dir as _ptk_resolve_lib, + ) + + lib_dir = _ptk_resolve_lib() + except Exception as exc: + logger.debug( + "resolve_palace_library_dir: palacetoolkit resolver failed: %s", + exc, + ) + return None + if lib_dir is not None and lib_dir.is_dir(): + return lib_dir.resolve() -def _binary_is_runnable(binary: Path, timeout: float = 15.0) -> bool: + return None + + +def _binary_is_runnable( + binary: Path, lib_dir: Path | None = None, timeout: float = 15.0 +) -> bool: """Smoke test: file exists, is executable, and responds to --version or --help.""" bin_str = str(binary) if not binary.is_file() or not os.access(bin_str, os.X_OK): return False + run_env = os.environ.copy() + if lib_dir is not None and lib_dir.is_dir(): + prior = run_env.get("LD_LIBRARY_PATH", "") + run_env["LD_LIBRARY_PATH"] = f"{lib_dir}:{prior}" if prior else str(lib_dir) + for flag in ("--version", "--help"): try: result = subprocess.run( # noqa: S603 @@ -130,6 +378,7 @@ def _binary_is_runnable(binary: Path, timeout: float = 15.0) -> bool: text=True, timeout=timeout, check=False, + env=run_env, ) if result.returncode == 0: return True diff --git a/tests/palace/test_sim_classes.py b/tests/palace/test_sim_classes.py index bddc6b05..6961c292 100644 --- a/tests/palace/test_sim_classes.py +++ b/tests/palace/test_sim_classes.py @@ -6,13 +6,10 @@ from __future__ import annotations +import os import sys from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Generator import pytest @@ -583,35 +580,34 @@ def _mock_gcloud(monkeypatch: pytest.MonkeyPatch) -> None: "print_job_summary", "run_simulation", ): - setattr(gcloud, name, lambda *a, **kw: None) # noqa: ARG005 + setattr(gcloud, name, lambda *a, **kw: None) gcloud.RunResult = type("RunResult", (), {}) # ty: ignore[unresolved-attribute] monkeypatch.setitem(sys.modules, "gsim.gcloud", gcloud) @pytest.fixture -def _no_palacetoolkit(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: - """Ensure palacetoolkit_palace_cpu is unavailable during the test.""" - if "palacetoolkit_palace_cpu" in sys.modules: - old = sys.modules["palacetoolkit_palace_cpu"] - monkeypatch.delitem(sys.modules, "palacetoolkit_palace_cpu", raising=False) - yield - sys.modules["palacetoolkit_palace_cpu"] = old - else: - yield +def _no_local_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate gsim's own cached/downloaded runtime during resolver tests.""" + import gsim.palace.runtime as rt + + monkeypatch.setattr(rt, "_cached_binary", lambda: None) + monkeypatch.setattr(rt, "_cached_library_dir", lambda: None) + monkeypatch.setattr(rt, "_is_linux_x86_64", lambda: False) + monkeypatch.setattr(rt, "_auto_download_enabled", lambda: False) + monkeypatch.setattr(rt, "_palace_cpu_available", lambda: False) + monkeypatch.setattr(rt, "_palace_toolkit_available", lambda: False) class TestResolvePalaceBinary: - @pytest.mark.usefixtures("_mock_gcloud", "_no_palacetoolkit") + @pytest.mark.usefixtures("_mock_gcloud", "_no_local_runtime") def test_returns_none_when_nothing_found(self) -> None: from gsim.palace.runtime import resolve_palace_binary with pytest.MonkeyPatch().context() as mp: mp.delenv("PALACE_BIN", raising=False) mp.delenv("PALACE_EXECUTABLE", raising=False) - with mp.context() as mp2: - mp2.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) - result = resolve_palace_binary() - assert result is None + result = resolve_palace_binary() + assert result is None @pytest.mark.usefixtures("_mock_gcloud") def test_uses_palace_bin_env(self) -> None: @@ -621,7 +617,7 @@ def test_uses_palace_bin_env(self) -> None: with pytest.MonkeyPatch().context() as mp: mp.setenv("PALACE_BIN", str(fake_bin)) - mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda _: True) + mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda *a, **k: True) mp.setattr("pathlib.Path.is_file", lambda _: True) result = resolve_palace_binary() assert result is not None @@ -640,11 +636,76 @@ def palace_binary_path() -> Path: with pytest.MonkeyPatch().context() as mp: mp.setitem(sys.modules, "palacetoolkit_palace_cpu", _FakePalaceCPU()) mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: True) - mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda _: True) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("gsim.palace.runtime._cached_binary", lambda: None) + mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda *a, **k: True) mp.setattr("pathlib.Path.is_file", lambda _: True) result = resolve_palace_binary() assert result is not None + @pytest.mark.usefixtures("_mock_gcloud") + def test_delegates_to_palacetoolkit_package(self) -> None: + from gsim.palace.runtime import resolve_palace_binary + + fake_ptk_bin = Path("/opt/palacetoolkit/runtime/bin/palace") + + import types + + ptk = types.ModuleType("palacetoolkit") + ptk.__path__ = [] # type: ignore[attr-defined] + ptk_runtime = types.ModuleType("palacetoolkit.palace_runtime") + setattr(ptk_runtime, "resolve_palace_binary", lambda: fake_ptk_bin) # noqa: B010 + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: True) + mp.setattr("gsim.palace.runtime._cached_binary", lambda: None) + mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda *a, **k: True) + mp.setattr("pathlib.Path.is_file", lambda _: True) + mp.setitem(sys.modules, "palacetoolkit", ptk) + mp.setitem(sys.modules, "palacetoolkit.palace_runtime", ptk_runtime) + result = resolve_palace_binary() + assert result is not None + + @pytest.mark.usefixtures("_mock_gcloud") + def test_uses_gsim_cached_runtime(self) -> None: + from gsim.palace.runtime import resolve_palace_binary + + fake_bin = Path("/home/user/.cache/palacetoolkit/runtime/bin/palace") + + with pytest.MonkeyPatch().context() as mp: + mp.delenv("PALACE_BIN", raising=False) + mp.delenv("PALACE_EXECUTABLE", raising=False) + mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("gsim.palace.runtime._cached_binary", lambda: fake_bin) + mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda *a, **k: True) + result = resolve_palace_binary() + assert result is not None + + @pytest.mark.usefixtures("_mock_gcloud") + def test_downloads_runtime_when_missing(self) -> None: + from gsim.palace.runtime import resolve_palace_binary + + fake_downloaded = Path("/home/user/.cache/palacetoolkit/runtime/bin/palace") + + with pytest.MonkeyPatch().context() as mp: + mp.delenv("PALACE_BIN", raising=False) + mp.delenv("PALACE_EXECUTABLE", raising=False) + mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("gsim.palace.runtime._cached_binary", lambda: None) + mp.setattr("gsim.palace.runtime._is_linux_x86_64", lambda: True) + mp.setattr("gsim.palace.runtime._auto_download_enabled", lambda: True) + mp.setattr( + "gsim.palace.runtime.install_palace_runtime", + lambda **k: fake_downloaded, + ) + mp.setattr("gsim.palace.runtime._cached_library_dir", lambda: None) + mp.setattr("gsim.palace.runtime._binary_is_runnable", lambda *a, **k: True) + result = resolve_palace_binary() + assert result == fake_downloaded.resolve() + @pytest.mark.usefixtures("_mock_gcloud") def test_prefer_bundled_skips_env(self) -> None: from gsim.palace.runtime import resolve_palace_binary @@ -652,18 +713,93 @@ def test_prefer_bundled_skips_env(self) -> None: with pytest.MonkeyPatch().context() as mp: mp.setenv("PALACE_BIN", "/usr/bin/palace") mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("gsim.palace.runtime._cached_binary", lambda: None) + mp.setattr("gsim.palace.runtime._auto_download_enabled", lambda: False) result = resolve_palace_binary(prefer_bundled=True) assert result is None +class TestInstallPalaceRuntime: + @pytest.mark.usefixtures("_mock_gcloud") + def test_returns_cached_binary_when_present(self, tmp_path: Path) -> None: + import gsim.palace.runtime as rt + + tag = "0.17.0" + with pytest.MonkeyPatch().context() as mp: + mp.setattr(rt, "_runtime_cache_dir", lambda: tmp_path) + mp.setattr(rt, "_binary_tag", lambda: tag) + prefix = tmp_path / f"palace-cpu-v{tag}" + (prefix / "bin").mkdir(parents=True) + (prefix / "lib").mkdir(parents=True) + bin_palace = prefix / "bin" / "palace" + bin_palace.write_text("#!/bin/sh\nexit 0\n") + bin_palace.chmod(0o755) + result = rt.install_palace_runtime(force=False) + assert result == bin_palace + + @pytest.mark.usefixtures("_mock_gcloud") + def test_raises_on_non_linux_x86_64(self) -> None: + import gsim.palace.runtime as rt + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(rt, "_is_linux_x86_64", lambda: False) + with pytest.raises(RuntimeError): + rt.install_palace_runtime() + + @pytest.mark.usefixtures("_mock_gcloud") + def test_downloads_and_extracts_runtime(self, tmp_path: Path) -> None: + import io + import zipfile + + import gsim.palace.runtime as rt + + tag = "0.9.9" + cache_dir = tmp_path / "cache" + + # Build a fake wheel in memory: payload with bin/palace and lib/libfoo.so + wheel_buf = io.BytesIO() + with zipfile.ZipFile(wheel_buf, "w") as zf: + zf.writestr("palacetoolkit_palace_cpu/bin/palace", "#!/bin/sh\nexit 0\n") + zf.writestr("palacetoolkit_palace_cpu/bin/palace-x86_64.bin", "x") + zf.writestr("palacetoolkit_palace_cpu/lib/libfoo.so", "libdata") + wheel_buf.seek(0) + + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return wheel_buf.getvalue() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(rt, "_runtime_cache_dir", lambda: cache_dir) + mp.setattr(rt, "_binary_tag", lambda: tag) + mp.setattr(rt, "_is_linux_x86_64", lambda: True) + mp.setattr( + rt, "_binary_wheel_url", lambda t: "https://example.invalid/x.whl" + ) + mp.setattr(rt, "_binary_wheel_url_from_release", lambda t, timeout: None) + mp.setattr(rt, "urlopen", lambda *a, **k: _FakeResponse()) + + result = rt.install_palace_runtime(force=False) + + prefix = cache_dir / f"palace-cpu-v{tag}" + assert result == prefix / "bin" / "palace" + assert (prefix / "bin" / "palace").is_file() + assert (prefix / "lib" / "libfoo.so").is_file() + assert os.access(result, os.X_OK) + + class TestResolvePalaceLibraryDir: - @pytest.mark.usefixtures("_mock_gcloud", "_no_palacetoolkit") - def test_returns_none_without_palacetoolkit(self) -> None: + @pytest.mark.usefixtures("_mock_gcloud", "_no_local_runtime") + def test_returns_none_without_runtime(self) -> None: from gsim.palace.runtime import resolve_palace_library_dir - with pytest.MonkeyPatch().context() as mp: - mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) - assert resolve_palace_library_dir() is None + assert resolve_palace_library_dir() is None @pytest.mark.usefixtures("_mock_gcloud") def test_delegates_to_palacetoolkit(self) -> None: @@ -679,7 +815,43 @@ def palace_library_path() -> Path: with pytest.MonkeyPatch().context() as mp: mp.setitem(sys.modules, "palacetoolkit_palace_cpu", _FakePalaceCPU()) mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: True) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("pathlib.Path.is_dir", lambda _: True) + result = resolve_palace_library_dir() + assert result is not None + + @pytest.mark.usefixtures("_mock_gcloud") + def test_uses_gsim_cached_library_dir(self) -> None: + from gsim.palace.runtime import resolve_palace_library_dir + + fake_lib = Path("/home/user/.cache/palacetoolkit/runtime/lib") + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: False) + mp.setattr("gsim.palace.runtime._cached_library_dir", lambda: fake_lib) + result = resolve_palace_library_dir() + assert result == fake_lib.resolve() + + @pytest.mark.usefixtures("_mock_gcloud") + def test_delegates_to_palacetoolkit_package(self) -> None: + from gsim.palace.runtime import resolve_palace_library_dir + + fake_lib = Path("/opt/palacetoolkit/runtime/lib") + + import types + + ptk = types.ModuleType("palacetoolkit") + ptk.__path__ = [] # type: ignore[attr-defined] + ptk_runtime = types.ModuleType("palacetoolkit.palace_runtime") + setattr(ptk_runtime, "resolve_palace_library_dir", lambda: fake_lib) # noqa: B010 + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("gsim.palace.runtime._palace_cpu_available", lambda: False) + mp.setattr("gsim.palace.runtime._palace_toolkit_available", lambda: True) mp.setattr("pathlib.Path.is_dir", lambda _: True) + mp.setitem(sys.modules, "palacetoolkit", ptk) + mp.setitem(sys.modules, "palacetoolkit.palace_runtime", ptk_runtime) result = resolve_palace_library_dir() assert result is not None @@ -687,11 +859,13 @@ def palace_library_path() -> Path: class TestPalacetoolkitAvailable: @pytest.mark.usefixtures("_mock_gcloud") def test_true_when_installed(self) -> None: - from gsim.palace.runtime import _palace_cpu_available - - # Since we mocked gcloud but not palacetoolkit_palace_cpu, if it's - # actually installed on the system, this will be True. We can't force - # it to be True via mock here without patching importlib, which is - # fragile. Instead we just verify the function runs. - result = _palace_cpu_available() - assert isinstance(result, bool) + from gsim.palace.runtime import ( + _palace_cpu_available, + _palace_toolkit_available, + ) + + # If either package is actually installed on the system, this will be + # True. We can't force it via mock here without patching importlib, + # which is fragile. Instead we just verify the functions run. + assert isinstance(_palace_cpu_available(), bool) + assert isinstance(_palace_toolkit_available(), bool) From 26d1d33c99e0cce9b7ed9b6b22e2a6893ab01149 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Wed, 19 Aug 2026 21:35:56 -0300 Subject: [PATCH 02/14] fix pre-commit --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5e16c5a9..069e1e94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,7 +275,6 @@ select = ["ALL"] ] "tests/**/*.py" = [ "ANN", # flake8-annotations - "ARG001", # unused-function-argument "ARG005", # unused-lambda-argument (test stubs) "D", # pydocstyle "INP001", # implicit-namespace-package From a9068341eeeaef8a55327ee4a7708b809073ff51 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Wed, 19 Aug 2026 22:25:53 -0300 Subject: [PATCH 03/14] fixed macOS and Windows tests: no auto-download available --- src/gsim/palace/runtime.py | 20 ++++++++++++-------- tests/palace/test_sim_classes.py | 5 ++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/gsim/palace/runtime.py b/src/gsim/palace/runtime.py index 47195eab..b75e46ff 100644 --- a/src/gsim/palace/runtime.py +++ b/src/gsim/palace/runtime.py @@ -110,20 +110,19 @@ def _set_executable(path: Path) -> None: def install_palace_runtime(force: bool = False, timeout: float = 180.0) -> Path: - """Download and cache the prebuilt Palace CPU runtime. + """Return a Palace runtime, downloading and caching it if needed. + + An already-cached runtime is returned on any platform; only the download + itself is restricted to Linux x86_64 (the only platform the prebuilt + Palace CPU wheel is provided for). Returns: Path to the cached ``palace`` launcher executable. Raises: - RuntimeError: If the platform is unsupported or the download/install - fails. + RuntimeError: If no runtime is cached, the platform is unsupported, + or the download/install fails. """ - if not _is_linux_x86_64(): - raise RuntimeError( - "Prebuilt runtime download is only supported on Linux x86_64" - ) - tag = _binary_tag() prefix = _cached_runtime_prefix(tag) bin_palace = prefix / "bin" / "palace" @@ -131,6 +130,11 @@ def install_palace_runtime(force: bool = False, timeout: float = 180.0) -> Path: if not force and bin_palace.is_file() and lib_dir.is_dir(): return bin_palace + if not _is_linux_x86_64(): + raise RuntimeError( + "Prebuilt runtime download is only supported on Linux x86_64" + ) + prefix.mkdir(parents=True, exist_ok=True) downloads = _runtime_cache_dir() / "downloads" downloads.mkdir(parents=True, exist_ok=True) diff --git a/tests/palace/test_sim_classes.py b/tests/palace/test_sim_classes.py index 6961c292..a11f7bb6 100644 --- a/tests/palace/test_sim_classes.py +++ b/tests/palace/test_sim_classes.py @@ -739,10 +739,13 @@ def test_returns_cached_binary_when_present(self, tmp_path: Path) -> None: assert result == bin_palace @pytest.mark.usefixtures("_mock_gcloud") - def test_raises_on_non_linux_x86_64(self) -> None: + def test_raises_on_non_linux_x86_64(self, tmp_path: Path) -> None: import gsim.palace.runtime as rt with pytest.MonkeyPatch().context() as mp: + # Use an empty cache dir so the fallthrough to the platform guard + # is deterministic regardless of what is cached on the host. + mp.setattr(rt, "_runtime_cache_dir", lambda: tmp_path) mp.setattr(rt, "_is_linux_x86_64", lambda: False) with pytest.raises(RuntimeError): rt.install_palace_runtime() From 22fee6048b89a68b357a2c2ca27562badddbc563 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Tue, 25 Aug 2026 23:45:31 -0300 Subject: [PATCH 04/14] feat: PN junction depletion model with auto capacitance/high-res modes --- CHANGELOG.md | 10 + docs/api/common.md | 32 ++ nbs/palace_2d_twmzm.ipynb | 330 ++++++++++++------- pyproject.toml | 2 +- src/gsim/common/cross_section.py | 6 + src/gsim/common/stack/__init__.py | 17 +- src/gsim/common/stack/doping.py | 224 ++++++++++++- src/gsim/common/stack/junction.py | 439 +++++++++++++++++++++++++ src/gsim/palace/base.py | 62 ++++ tests/common/test_cross_section.py | 38 +++ tests/common/test_junction_physics.py | 215 ++++++++++++ tests/common/test_junction_profile.py | 180 ++++++++++ tests/palace/test_pn_junction_modes.py | 159 +++++++++ 13 files changed, 1598 insertions(+), 116 deletions(-) create mode 100644 src/gsim/common/stack/junction.py create mode 100644 tests/common/test_junction_physics.py create mode 100644 tests/common/test_junction_profile.py create mode 100644 tests/palace/test_pn_junction_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d15c3ff3..2574bc8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- PN-junction depletion model from Sze *Physics of Semiconductor Devices* (`PNJunctionConfig`, + `make_pn_junction_profile`): computes built-in voltage, depletion width `W` (abrupt or linearly graded), asymmetric + P/N split `x_p`/`x_n`, and capacitance `C_j = eps_s A / W`. The depletion region is represented automatically — meshed + as a dielectric strip in high-res mode when `W >= ~1/5` of the flanking doped sections, otherwise applied as a lumped + Impedance boundary via `sim.set_pn_junction()`. The 2D TWMZM demo now illustrates both modes. +- Fix: `build_doped_cross_section()` now registers doping/rib materials on `stack.materials`; previously doped domains + silently resolved to eps=1.0 without conductivity in generated Palace configs. + ## 0.1.0 - Electrostatic simulation end-to-end for Palace ([#146](https://github.com/gdsfactory/gsim/pull/146)) diff --git a/docs/api/common.md b/docs/api/common.md index 332cd738..514e65d1 100644 --- a/docs/api/common.md +++ b/docs/api/common.md @@ -38,6 +38,38 @@ inherited_members: false members: false +## PN Junction + +Depletion model after Sze & Ng, *Physics of Semiconductor Devices*, ch. 2. + +::: gsim.common.stack.PNJunctionConfig + options: + show_source: false + +::: gsim.common.stack.make_pn_junction_profile + options: + show_source: false + +::: gsim.common.stack.built_in_voltage + options: + show_source: false + +::: gsim.common.stack.depletion_width + options: + show_source: false + +::: gsim.common.stack.depletion_extents + options: + show_source: false + +::: gsim.common.stack.junction_capacitance_per_area + options: + show_source: false + +::: gsim.common.stack.select_junction_mode + options: + show_source: false + ## Visualization ::: gsim.common.viz.plot_prisms_3d diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index bf3bf6a9..0806ecc9 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -30,7 +30,7 @@ "id": "1", "metadata": {}, "source": [ - "## Geometry & materials parameters\n", + "## 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", @@ -44,6 +44,7 @@ "- `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", + "- `(22,0)`: Depletion/junction strip (drawn only in high-res mode)\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" ] @@ -80,10 +81,19 @@ "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", + "# --- PN junction depletion model (Sze ch. 2) ---------------------------------\n", + "# W = sqrt(2 eps_s (V_bi + V_R)/q * (Na+Nd)/(Na Nd)); x_p/x_n split the\n", + "# depletion into the P/N sides; C_j = eps_s A / W. Doping in cm^-3.\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", + "PN_RIB_SIGMA = 1.6e3 # Drude conductivity of the P/N rib regions (S/m)\n", + "PN_JUNCTION = {\n", + " \"na_cm3\": 1e19,\n", + " \"nd_cm3\": 1e19,\n", + " \"v_reverse\": 0.0,\n", + " \"permittivity\": SI_PERMITTIVITY,\n", + "}\n", + "JUNCTION_GDS_LAYER = (22, 0) # depletion-strip GDS layer (drawn only in high-res)\n", "\n", "# Graded slab doping {side: [(width_um, sigma_S_per_m), ...]}, from the rib edge.\n", "DOPING_PROFILE = {\n", @@ -104,6 +114,68 @@ "cell_type": "markdown", "id": "3", "metadata": {}, + "source": [ + "## PN-junction width & automatic representation mode\n", + "\n", + "`make_pn_junction_profile()` splits the rib into P / depletion / N regions\n", + "using the depletion approximation (Sze & Ng, *Physics of Semiconductor\n", + "Devices*, 3rd ed., ch. 2):\n", + "\n", + "$$V_{bi} = \\frac{k_B T}{q}\\ln\\frac{N_A N_D}{n_i^2}, \\qquad\n", + "W = \\sqrt{\\frac{2 \\varepsilon_s (V_{bi}+V_R)}{q}\\frac{N_A+N_D}{N_A N_D}}, \\qquad\n", + "C_j = \\frac{\\varepsilon_s}{W}$$\n", + "\n", + "Two representations, selected automatically from $W$:\n", + "\n", + "- **capacitance**: $W$ is far thinner than the neighbouring doped sections\n", + " ($W < \\tfrac{1}{5}$ of a flank). The P/N geometry stays unchanged and the\n", + " computed $C_j$ is applied as a lumped Impedance boundary.\n", + "- **high_res**: $W$ is comparable to the flanks. The depletion strip is drawn\n", + " as a contiguous dielectric rectangle ($\\varepsilon_s$, no carriers) that is\n", + " resolved on the actual mesh.\n", + "\n", + "Both modes return the doped regions, the P/N regions, and the junction\n", + "metadata (widths, capacitance, chosen mode).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from gsim.common.stack.junction import PNJunctionConfig\n", + "\n", + "junc = PNJunctionConfig.model_validate(PN_JUNCTION)\n", + "flank = RIB_WIDTH / 2\n", + "print(\n", + " f\"Bias point: V_bi = {junc.v_bi:.3f} V, W = {junc.w_um * 1e3:.1f} nm \"\n", + " f\"(x_p = {junc.xp_um * 1e3:.1f} nm, x_n = {junc.xn_um * 1e3:.1f} nm)\"\n", + ")\n", + "print(\n", + " f\"C_j = eps_s A / W = {junc.capacitance(LENGTH, RIB_HEIGHT) * 1e15:.2f} fF \"\n", + " f\"(A = {LENGTH} x {RIB_HEIGHT} um)\"\n", + ")\n", + "print(\n", + " f\"Flank size = {flank * 1e3:.0f} nm -> auto mode selects \"\n", + " f\"'{junc.select_mode(flank, flank)}'\\n\"\n", + ")\n", + "\n", + "print(\"How doping moves W across the auto-selection threshold:\")\n", + "for n_cm3 in (1e19, 5e18, 2e18, 1e18):\n", + " j = PNJunctionConfig(na_cm3=n_cm3, nd_cm3=n_cm3)\n", + " mode = j.select_mode(flank, flank)\n", + " print(\n", + " f\" Na = Nd = {n_cm3:.1e} cm^-3 : W = {j.w_um * 1e3:6.1f} nm, \"\n", + " f\"C = {j.capacitance(LENGTH, RIB_HEIGHT) * 1e15:6.2f} fF -> {mode}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, "source": [ "## Build TW-MZM cross-section geometry\n", "\n", @@ -114,14 +186,14 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "6", "metadata": {}, "outputs": [], "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", + "from gsim.common.stack.doping import make_doping_profile, make_pn_junction_profile\n", "\n", "gf.gpdk.PDK.activate()\n", "\n", @@ -134,12 +206,14 @@ " return r\n", "\n", "\n", - "def _add_device_core(comp: gf.Component) -> None:\n", + "def _add_device_core(comp: gf.Component) -> dict:\n", " \"\"\"Rib + slab + PN junction — shared by the RF and optical components.\n", "\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", + " The PN-junction regions come from ``make_pn_junction_profile()``: the\n", + " depletion width W (and its x_p/x_n split) follows from the configured\n", + " doping/bias, and the representation mode is auto-selected. In\n", + " capacitance mode the P/N rectangles stay adjacent; in high-res mode a\n", + " contiguous depleted-Si strip of width W is drawn between them.\n", " \"\"\"\n", " # 1. Rib waveguide core (PN junction sits inside it)\n", " wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", @@ -149,17 +223,26 @@ " 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 / depletion / N regions around the rib centre\n", + " return make_pn_junction_profile(\n", + " comp,\n", + " length=LENGTH,\n", + " center_y=RIB_CENTER_Y,\n", + " rib_width=RIB_WIDTH,\n", + " junction=PN_JUNCTION,\n", + " p_region=(\"p_rib\", tuple(LAYER.P), PN_RIB_SIGMA),\n", + " n_region=(\"n_rib\", tuple(LAYER.N), PN_RIB_SIGMA),\n", + " junction_region=(\"junction\", JUNCTION_GDS_LAYER),\n", + " zmin=0.0,\n", + " zmax=RIB_HEIGHT,\n", + " fmax=FMAX_RF_MATERIAL,\n", + " )\n", "\n", "\n", - "def _build_rf_component() -> tuple[gf.Component, dict]:\n", + "def _build_rf_component() -> tuple[gf.Component, dict, dict]:\n", " \"\"\"Full TW-MZM cross-section: device core + graded doping + CPW + vias.\"\"\"\n", " comp = gf.Component()\n", - " _add_device_core(comp)\n", + " pn_result = _add_device_core(comp)\n", "\n", " # 4. Graded N+/P+ slab doping (contiguous, no gaps)\n", " doping_result = make_doping_profile(\n", @@ -195,35 +278,30 @@ " 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", + " return comp, doping_result, pn_result\n", "\n", "\n", - "def _build_optical_component() -> gf.Component:\n", + "def _build_optical_component() -> tuple[gf.Component, dict]:\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", + " pn_result = _add_device_core(comp)\n", + " return comp, pn_result\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", + "# --- RF component (electrodes, vias, graded doping) ---------------------------\n", + "comp, doping_result, pn_result = _build_rf_component()\n", "\n", - "# -- Plot ----------------------------------------------------------------------\n", - "_cc = comp.copy()\n", - "_cc.draw_ports()\n", - "_cc.plot()" + "# --- Optical-only component ----------------------------------------------------\n", + "comp_optical, pn_result_optical = _build_optical_component()" ] }, { "cell_type": "markdown", - "id": "5", + "id": "7", "metadata": {}, "source": [ "## Inspect 2D cross-section\n", @@ -236,7 +314,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -247,29 +325,36 @@ "\n", "from gsim.common.cross_section import build_doped_cross_section\n", "\n", + "# The reusable `gsim.common.cross_section.build_doped_cross_section()` helper\n", + "# assembles the base PDK stack and overrides `metal1`. The graded-slab doping\n", + "# specs and the PN-junction specs (P/N plus the depletion strip when meshed)\n", + "# are merged into a single `doping=` input.\n", + "\n", + "doping_input = {\n", + " \"layer_specs\": {**doping_result[\"layer_specs\"], **pn_result[\"layer_specs\"]},\n", + " \"materials\": {**doping_result[\"materials\"], **pn_result[\"materials\"]},\n", + "}\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", + " doping=doping_input,\n", " permittivity=SI_PERMITTIVITY,\n", " fmax=FMAX_RF_MATERIAL,\n", - ")" + ")\n", + "\n", + "print(\"PN-junction representation:\", pn_result[\"junction\"][\"mode\"])" ] }, { "cell_type": "markdown", - "id": "7", + "id": "9", "metadata": {}, "source": [ - "## Optical-only cross-section\n", + "### 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", @@ -277,29 +362,36 @@ "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$." + "minimal all-dielectric `LayerStack` and extracts the 2D cross-section at $x=0$.\n", + "When the auto-selected PN representation is high-res, the depletion strip is\n", + "included as an extra silicon region.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "10", "metadata": {}, "outputs": [], "source": [ "# Uniform SiO2 cladding height above z=0 (um)\n", "OPT_CLAD_TOP = 3.0\n", "\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", + "if \"junction\" in pn_result_optical[\"layer_specs\"]:\n", + " # High-res mode drew the depletion strip; it is plain silicon optically.\n", + " device_layers[\"junction\"] = (JUNCTION_GDS_LAYER, 0.0, RIB_HEIGHT)\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", + " device_layers=device_layers,\n", " substrate_thickness=BOX_THICKNESS,\n", " cladding_top=OPT_CLAD_TOP,\n", ")\n", @@ -310,7 +402,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "11", "metadata": {}, "source": [ "### Optical cross-section plot\n", @@ -322,7 +414,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -354,7 +446,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "13", "metadata": {}, "source": [ "### Optical material properties (1550 nm)\n", @@ -367,7 +459,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +481,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "15", "metadata": {}, "source": [ "## Plot the 2D cross-section\n", @@ -402,7 +494,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -430,7 +522,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -451,7 +543,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "18", "metadata": {}, "source": [ "## RF simulation (50 GHz)\n", @@ -463,7 +555,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -499,14 +591,13 @@ " \"pp_slab_1\",\n", "]\n", "\n", - "# --- PN junction lumped model -------------------------------------------------\n", - "PN_JUNCTION_CAPACITANCE = 1e-15 # F, on the p_rib / n_rib interface" + "# --- PN junction lumped model -------------------------------------------------" ] }, { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -541,7 +632,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -556,12 +647,31 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "22", "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", + "# Generate Palace config file (mesh must be present).\n", + "#\n", + "# Capacitance mode: apply C_j = eps_s A / W as a lumped Impedance boundary\n", + "# on the p_rib/n_rib interface via `set_pn_junction()`.\n", + "# High-res mode: the depletion strip already exists as dielectric geometry on\n", + "# the mesh — no lumped boundary is needed (and adding one would double-count).\n", + "if pn_result[\"junction\"][\"mode\"] == \"capacitance\":\n", + " applied_c = sim.set_pn_junction(\n", + " PN_JUNCTION,\n", + " layer_p=\"p_rib\",\n", + " layer_n=\"n_rib\",\n", + " length_um=LENGTH,\n", + " height_um=RIB_HEIGHT,\n", + " )\n", + " print(f\"Applied lumped junction capacitance: {applied_c * 1e15:.2f} fF\")\n", + "else:\n", + " print(\n", + " f\"High-res mode: depletion strip W = \"\n", + " f\"{pn_result['junction']['w_um'] * 1e3:.1f} nm meshed as dielectric.\"\n", + " )\n", + "\n", "sim.write_config()\n", "print(\"Config written to:\", sim.output_dir)" ] @@ -569,7 +679,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -581,7 +691,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -611,7 +721,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -629,7 +739,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "26", "metadata": {}, "source": [ "## Optical simulation (1550 nm)\n", @@ -649,7 +759,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "27", "metadata": { "lines_to_next_cell": 2 }, @@ -680,7 +790,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -735,7 +845,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -750,7 +860,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -761,7 +871,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -779,7 +889,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -795,7 +905,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "33", "metadata": {}, "source": [ "## Summary\n", @@ -803,53 +913,51 @@ "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", + "| Component | Layer | Material | sigma (S/m) |\n", + "|---|---|---|---|\n", + "| Rib core | WG (1,0) | Si (intrinsic) | 2 |\n", + "| Slab (90 nm) | SLAB90 (3,0) | Si (intrinsic) | 2 |\n", + "| PN junction (P) | P (21,0) | doped Si (p_rib) | 1.6x10^3 |\n", + "| Depletion strip | (22,0) | Si (eps 11.9, undoped) | — |\n", + "| PN junction (N) | N (20,0) | doped Si (n_rib) | 1.6x10^3 |\n", + "| P+ graded inner/outer | PP (23,0)/(23,1) | doped Si | 2e4 / 8e4 |\n", + "| N+ graded inner/outer | NPP (24,0)/(24,1) | doped Si | 2e4 / 8e4 |\n", + "| Vias (S->P+, G->N+) | VIAC/VIA1/VIA2 | W/Al | 3.5x10^7 |\n", + "| CPW signal / grounds | M1 (41,0) | Al (1 um) | 3.5x10^7 |\n", + "\n", + "**PN-junction model** (Sze & Ng, *Physics of Semiconductor Devices*, 3rd ed., ch. 2):\n", + "- Built-in voltage $V_{bi} = \\frac{k_BT}{q}\\ln(N_AN_D/n_i^2)$.\n", + "- Abrupt-junction depletion width $W = \\sqrt{\\frac{2\\varepsilon_s(V_{bi}+V_R)}{q}\\frac{N_A+N_D}{N_A N_D}}$,\n", + " split asymmetrically $x_p = W N_D/(N_A+N_D)$ into P and $x_n = W N_A/(N_A+N_D)$ into N;\n", + " linearly graded junctions use $W = [12\\varepsilon_s(V_{bi}+V_R)/(qa)]^{1/3}$.\n", + "- Junction capacitance $C_j = \\varepsilon_s A / W$.\n", + "\n", + "**Representation modes (auto-selected from $W$ vs the flanking doped sections):**\n", + "- **capacitance**: $W$ below ~1/5 of a flank -> P/N geometry unchanged; $C_j$\n", + " applied as a lumped Impedance boundary (`sim.set_pn_junction()`).\n", + "- **high_res**: $W$ comparable to the flanks -> contiguous depleted-Si strip of\n", + " width $W$ drawn between P and N ($\\varepsilon_s$, no carriers) and resolved\n", + " on the actual mesh; no lumped boundary.\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", + "- Doped regions are modelled as **semiconductors** (finite Drude $\\sigma$),\n", + " not metals, so they do not short-circuit the junction.\n", + "- The depletion region is now represented either lumped or geometrically\n", + " (auto-selected); earlier revisions omitted it entirely.\n", + "- The **plasma-dispersion effect** is not applied to the optical simulation;\n", + " the rib is treated as intrinsic Si at 1550 nm.\n", "\n", "**Optical-only component:**\n", "- The optical run (`sim_optical`) uses a **simplified component** — the same\n", - " rib + slab + PN junction (\"doping profile\") polygons, but no electrodes,\n", - " vias, or graded doping.\n", + " rib + slab + PN junction polygons, but no electrodes, vias, or graded doping.\n", "- Every device region maps to **plain silicon** in `build_optical_cross_section()`,\n", - " so the optical mode sees one homogeneous Si body in a **uniform SiO2 cladding**.\n", - "- The background medium is SiO2, not air: `set_airbox(material=\"sio2\", ...)`\n", - " fills the padded 2D domain with the cladding material (default is air).\n", - "- Material dispersion is evaluated at `F_OPT` by the boundary-mode config\n", - " generator: Si -> eps~12.09 (n~3.478), SiO2 -> eps~2.09 (n~1.444) 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. To run locally, provide a Palace CPU runner via `PALACE_BIN` or as `palace` on PATH. 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_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" + " so the optical mode sees one homogeneous Si body in uniform SiO2.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ diff --git a/pyproject.toml b/pyproject.toml index 069e1e94..2b27102d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ requires = ["build", "setuptools>=61", "uv", "wheel"] build-backend = "setuptools.build_meta" [tool.codespell] -ignore-words-list = "doubleclick,euclidian,te" +ignore-words-list = "doubleclick,euclidian,te,nd" [tool.interrogate] docstring-style = "google" diff --git a/src/gsim/common/cross_section.py b/src/gsim/common/cross_section.py index 170d38e5..61b094ef 100644 --- a/src/gsim/common/cross_section.py +++ b/src/gsim/common/cross_section.py @@ -305,6 +305,12 @@ def build_doped_cross_section( for name, layer in layer_specs.items(): stack.layers[name] = layer + # Register the doping/rib materials on the stack so downstream consumers + # (Palace config generator, Meep, ...) resolve their eps/sigma instead of + # silently falling back to vacuum. + for name, mat in materials.items(): + stack.materials[name] = mat.to_dict() if hasattr(mat, "to_dict") else mat + section = extract_plane_section( component.copy(), stack, diff --git a/src/gsim/common/stack/__init__.py b/src/gsim/common/stack/__init__.py index f947263a..db2b7aa4 100644 --- a/src/gsim/common/stack/__init__.py +++ b/src/gsim/common/stack/__init__.py @@ -23,7 +23,7 @@ import gdsfactory as gf import yaml -from gsim.common.stack.doping import make_doping_profile +from gsim.common.stack.doping import make_doping_profile, make_pn_junction_profile from gsim.common.stack.extractor import ( Layer, LayerStack, @@ -31,6 +31,14 @@ extract_from_pdk, extract_layer_stack, ) +from gsim.common.stack.junction import ( + PNJunctionConfig, + built_in_voltage, + depletion_extents, + depletion_width, + junction_capacitance_per_area, + select_junction_mode, +) from gsim.common.stack.materials import ( MATERIALS_DB, DispersionModel, @@ -170,25 +178,32 @@ def load_stack_yaml(yaml_path: str | Path) -> LayerStack: "LayerStack", "LorentzianTerm", "MaterialProperties", + "PNJunctionConfig", "ResolvedMaterial", "SellmeierTerm", "StackLayer", "ValidationResult", "ValidityRange", + "built_in_voltage", + "depletion_extents", + "depletion_width", "extract_from_pdk", "extract_layer_stack", "get_material_properties", "get_stack", + "junction_capacitance_per_area", "load_overlay", "load_stack_yaml", "make_doped_material", "make_doped_materials", "make_doping_profile", + "make_pn_junction_profile", "merge_overlay", "parse_layer_stack", "plot_stack", "print_stack", "print_stack_table", "resolve_material_at_wavelength", + "select_junction_mode", "should_enable_dispersion", ] diff --git a/src/gsim/common/stack/doping.py b/src/gsim/common/stack/doping.py index 51de63c8..2d8d50ec 100644 --- a/src/gsim/common/stack/doping.py +++ b/src/gsim/common/stack/doping.py @@ -41,15 +41,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +import logging +from typing import TYPE_CHECKING, Any, Literal, cast import gdsfactory as gf -from gsim.common.stack.materials import make_doped_materials +from gsim.common.stack.junction import ( + JUNCTION_MODE_FRACTION, + PNJunctionConfig, +) +from gsim.common.stack.materials import MaterialProperties, make_doped_materials if TYPE_CHECKING: from gsim.common.stack.extractor import Layer +logger = logging.getLogger(__name__) + _SideConfig = dict[str, dict[str, Any]] @@ -163,4 +170,215 @@ def make_doping_profile( return result -__all__ = ["make_doping_profile"] +def _as_junction_config( + junction: PNJunctionConfig | dict[str, Any], +) -> PNJunctionConfig: + """Accept a config object or plain dict for the junction parameters.""" + if isinstance(junction, PNJunctionConfig): + return junction + return PNJunctionConfig.model_validate(junction) + + +def _add_rect( + comp: gf.Component, + *, + length: float, + y0: float, + y1: float, + gds_layer: tuple[int, int], +) -> float: + """Draw a rectangle spanning ``[y0, y1]`` and return its y-centre.""" + rect = comp << gf.c.rectangle((length, y1 - y0), layer=gds_layer) + rect.y = (y0 + y1) / 2 + return (y0 + y1) / 2 + + +def make_pn_junction_profile( + comp: gf.Component, + *, + length: float, + center_y: float, + rib_width: float, + junction: PNJunctionConfig | dict[str, Any], + p_region: tuple[str, tuple[int, int], float], + n_region: tuple[str, tuple[int, int], float], + junction_region: tuple[str, tuple[int, int]] | None = None, + zmin: float = 0.0, + zmax: float | None = None, + fmax: float = 200e9, + mode: Literal["auto", "capacitance", "high_res"] = "auto", + mode_fraction: float = JUNCTION_MODE_FRACTION, + mesh_resolution: str | float = "fine", +) -> dict[str, dict[str, Any]]: + """Build P / depletion-junction / N rib regions around ``center_y``. + + The depletion width ``W`` (and its asymmetric split ``xp``/``xn`` into + the P and N halves) comes from :class:`PNJunctionConfig`, which + implements the textbook abrupt/linearly-graded junction formulas + (Sze, *Physics of Semiconductor Devices*, ch. 2). + + Two representation modes are supported: + + - ``"high_res"``: three contiguous rectangles are drawn — N + ``[cy - rib_width/2, cy - xn]``, depleted-junction dielectric strip + ``[cy - xn, cy + xp]``, P ``[cy + xp, cy + rib_width/2]``. The + junction strip is registered as a patterned dielectric with a real + GDS layer so it appears on the simulation mesh. + - ``"capacitance"``: geometry is unchanged from a plain P/N split + (adjacent half-rectangles); no junction polygon is drawn and callers + apply the computed capacitance as a lumped impedance boundary instead + (see ``PalaceSimMixin.set_pn_junction``). + + With ``mode="auto"`` the choice falls out of + :func:`gsim.common.stack.junction.select_junction_mode`: the strip is + meshed only when ``W >= mode_fraction * min(P flank, N flank)``, where + each flank is ``rib_width / 2``. + + Args: + comp: gdsfactory component the rectangles are added to. + length: Rectangle length along the propagation direction (um). + center_y: Y coordinate of the metallurgical junction / rib centre. + rib_width: Full rib width (um); P occupies the upper half, N the + lower half. + junction: Depletion-model parameters + (:class:`PNJunctionConfig` or its dict form). + p_region: ``(name, gds_layer, sigma_S_per_m)`` for the P region. + n_region: ``(name, gds_layer, sigma_S_per_m)`` for the N region. + junction_region: ``(name, gds_layer)`` used to register the + depletion strip in high-res mode. Required when the selected + mode is ``"high_res"``; ignored in capacitance mode. + zmin: Bottom z of the regions (um). + zmax: Top z of the regions (um); defaults to ``zmin + 0.22``. + fmax: Upper frequency of the Drude-model validity range (Hz). + mode: ``"auto"``, ``"capacitance"`` or ``"high_res"``. + mode_fraction: Auto-mode threshold fraction (~1/5 default). + mesh_resolution: Mesh resolution assigned to the generated layers. + + Returns: + Dict with keys: + + - ``layer_specs``: ``{name: Layer}`` for every drawn region. + - ``materials``: ``{name: MaterialProperties}`` (Drude models for + P/N, plain dielectric for the junction strip). + - ``centres``: ``{role: y_centre}`` for drawn regions. + - ``junction``: computed quantities (widths, capacitance, chosen + mode and selection reason). + """ + from gsim.common.stack.extractor import Layer + from gsim.common.stack.junction import select_junction_mode + + cfg = _as_junction_config(junction) + p_name, p_layer, p_sigma = p_region + n_name, n_layer, n_sigma = n_region + + ztop = 0.22 if zmax is None else zmax + if ztop <= zmin: + raise ValueError("zmax must exceed zmin.") + if length <= 0: + raise ValueError("length must be positive.") + if cfg.xp_um + cfg.xn_um > rib_width: + raise ValueError( + f"Depletion width W={cfg.w_um:.4g} um does not fit in the " + f"{rib_width:.4g} um rib." + ) + + flank_um = rib_width / 2 + if mode == "auto": + mode = select_junction_mode( + cfg.w_um, flank_um, flank_um, fraction=mode_fraction + ) + reason = ( + f"W={cfg.w_um:.4g} um vs threshold " + f"{mode_fraction * flank_um:.4g} um (= {mode_fraction} * flank)" + ) + else: + reason = f"forced by caller (mode={mode!r})" + logger.info("PN junction mode: %s (%s)", mode, reason) + + 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, float] = result["centres"] + + def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: + return Layer( + name=name, + gds_layer=gds_layer, + zmin=zmin, + zmax=ztop, + thickness=ztop - zmin, + material=name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + + xp, xn = cfg.xp_um, cfg.xn_um + + # N region: lower half, trimmed by xn when the strip is meshed. + n_y0 = center_y - flank_um + n_y1 = center_y if mode == "capacitance" else center_y - xn + centres["n"] = _add_rect( + comp, length=length, y0=n_y0, y1=n_y1, gds_layer=tuple(n_layer) + ) + layer_specs[n_name] = _doped_spec(n_name, tuple(n_layer), n_sigma) + + # P region: upper half, trimmed by xp when the strip is meshed. + p_y0 = center_y if mode == "capacitance" else center_y + xp + p_y1 = center_y + flank_um + centres["p"] = _add_rect( + comp, length=length, y0=p_y0, y1=p_y1, gds_layer=tuple(p_layer) + ) + layer_specs[p_name] = _doped_spec(p_name, tuple(p_layer), p_sigma) + + materials.update( + make_doped_materials( + [(p_name, p_sigma), (n_name, n_sigma)], + permittivity=cfg.permittivity, + fmax=fmax, + source_prefix="doped Si", + ) + ) + + if mode == "high_res": + if junction_region is None: + raise ValueError( + "mode='high_res' requires junction_region=(name, gds_layer)." + ) + j_name, j_layer = junction_region + centres["junction"] = _add_rect( + comp, + length=length, + y0=center_y - xn, + y1=center_y + xp, + gds_layer=tuple(j_layer), + ) + layer_specs[j_name] = Layer( + name=j_name, + gds_layer=tuple(j_layer), + zmin=zmin, + zmax=ztop, + thickness=ztop - zmin, + material=j_name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + # Depleted silicon has no free carriers: pure real permittivity. + materials[j_name] = MaterialProperties( + permittivity=cfg.permittivity, + dispersion_models=[], + ) + + result["junction"] = { + **cfg.to_metadata(), + "c_f": cfg.capacitance(length, ztop - zmin), + "mode": mode, + "selection_reason": reason, + } + return result + + +__all__ = ["make_doping_profile", "make_pn_junction_profile"] diff --git a/src/gsim/common/stack/junction.py b/src/gsim/common/stack/junction.py new file mode 100644 index 00000000..3e796aca --- /dev/null +++ b/src/gsim/common/stack/junction.py @@ -0,0 +1,439 @@ +"""PN-junction depletion model (Sze, *Physics of Semiconductor Devices*). + +This module implements the textbook depletion approximation for an abrupt or +linearly graded PN junction: + +- S. M. Sze and K. K. Ng, *Physics of Semiconductor Devices*, 3rd ed., + Wiley (2007), chapter 2 ("p-n Junction Diodes"). + +Provided quantities (all concentrations in ``cm^-3``, lengths in ``um``): + +1. Built-in potential:: + + V_bi = (k_B T / q) ln(Na Nd / ni^2) (Sze eq. 2.60) + +2. Depletion width under reverse bias VR (abrupt junction):: + + W = sqrt( 2 eps_s (V_bi + VR) / q * (Na + Nd)/(Na Nd) ) (eq. 2.66) + x_p = W Nd / (Na + Nd) (spilled into the P side) + x_n = W Na / (Na + Nd) (spilled into the N side) + +3. Depletion width for a linearly graded junction with grade constant + ``a = |dN/dx|`` near the metallurgical junction:: + + W = [ 12 eps_s (V_bi + VR) / (q a) ]^(1/3) (eq. 2.72) + +4. Junction capacitance per unit area (parallel-plate form of the depletion + charge, valid for W much smaller than the device lateral dimensions):: + + C_j = eps_s / W + +The same module also provides :func:`select_junction_mode`, which decides +whether the depletion strip can be resolved on the simulation mesh +(``"high_res"``) or should be collapsed into a lumped capacitance boundary +(``"capacitance"``). + +Example: +------- + >>> from gsim.common.stack.junction import PNJunctionConfig + >>> junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=0.0) + >>> junc.v_bi # built-in potential [V] + >>> junc.w_um # total depletion width [um] + >>> junc.xp_um # depletion extent into the P side [um] + >>> junc.xn_um # depletion extent into the N side [um] + >>> junc.capacitance(length_um=10.0, height_um=0.22) # absolute C [F] +""" + +from __future__ import annotations + +import math +from typing import Any, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from scipy.constants import Boltzmann as KB # noqa: N814 +from scipy.constants import elementary_charge as Q # noqa: N812 +from scipy.constants import epsilon_0 as EPS0 # noqa: N812 + +__all__ = [ + "DEFAULT_SI_PERMITTIVITY", + "JUNCTION_MODE_FRACTION", + "NI_SI_300K_CM3", + "PNJunctionConfig", + "built_in_voltage", + "depletion_extents", + "depletion_width", + "junction_capacitance_per_area", + "select_junction_mode", +] + +#: Intrinsic carrier concentration of silicon at 300 K in cm^-3. +#: Classic textbook value used by Sze; override for other materials/T. +NI_SI_300K_CM3: float = 1.5e10 + +#: Default relative permittivity of depleted (intrinsic) silicon. +DEFAULT_SI_PERMITTIVITY: float = 11.9 + +#: A depletion width is considered mesh-resolvable when it reaches this +#: fraction of the smallest doped section flanking the junction. +JUNCTION_MODE_FRACTION: float = 0.2 + +JunctionMode = Literal["capacitance", "high_res"] + + +def built_in_voltage( + na_cm3: float, + nd_cm3: float, + *, + temperature_k: float = 300.0, + ni_cm3: float = NI_SI_300K_CM3, +) -> float: + """Compute the built-in potential ``V_bi`` of a PN junction in volts. + + Implements ``V_bi = (k_B T / q) ln(Na Nd / ni^2)`` (Sze ch. 2). + + Args: + na_cm3: Acceptor concentration on the P side in cm^-3 (> 0). + nd_cm3: Donor concentration on the N side in cm^-3 (> 0). + temperature_k: Lattice temperature in kelvin (> 0). + ni_cm3: Intrinsic carrier concentration in cm^-3 (> 0). + + Returns: + Built-in potential in volts. + + Raises: + ValueError: If any input is non-positive or ``Na*Nd <= ni**2``. + """ + if na_cm3 <= 0 or nd_cm3 <= 0: + raise ValueError("Doping concentrations must be positive (cm^-3).") + if temperature_k <= 0: + raise ValueError("temperature_k must be positive.") + if ni_cm3 <= 0: + raise ValueError("ni_cm3 must be positive.") + product = na_cm3 * nd_cm3 + if product <= ni_cm3**2: + raise ValueError( + f"Na*Nd ({product:.3g} cm^-6) must exceed ni^2 " + f"({ni_cm3**2:.3g} cm^-6); degenerate case has no junction." + ) + vt = KB * temperature_k / Q + return float(vt * math.log(product / ni_cm3**2)) + + +def _validate_bias(v_reverse: float, v_bi: float) -> None: + """Reject bias points beyond flat-band (no physical solution).""" + if v_bi + v_reverse <= 0: + raise ValueError( + f"V_bi + v_reverse = {v_bi + v_reverse:.4g} V must be > 0 " + "(applied forward bias beyond flat-band has no solution)." + ) + + +def _eps_si(permittivity: float) -> float: + """Return absolute permittivity in F/m from a relative value.""" + if permittivity < 1.0: + raise ValueError("permittivity must be >= 1.") + return permittivity * EPS0 + + +def depletion_width( + na_cm3: float, + nd_cm3: float, + *, + v_reverse: float = 0.0, + temperature_k: float = 300.0, + ni_cm3: float = NI_SI_300K_CM3, + permittivity: float = DEFAULT_SI_PERMITTIVITY, + grading: Literal["abrupt", "linear"] = "abrupt", + grade_const_cm4: float | None = None, +) -> float: + """Compute the total depletion width ``W`` in micrometers. + + Args: + na_cm3: Acceptor concentration in cm^-3 (> 0). + nd_cm3: Donor concentration in cm^-3 (> 0). + v_reverse: Applied reverse-bias voltage in volts (positive = reverse). + Negative values model forward bias down to (but excluding) + flat-band. + temperature_k: Lattice temperature in kelvin. + ni_cm3: Intrinsic carrier concentration in cm^-3. + permittivity: Relative permittivity of the semiconductor. + grading: ``"abrupt"`` (step junction) or ``"linear"`` (linearly + graded). + grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4 for + ``grading="linear"``. + + Returns: + Total depletion width in micrometers. + + Raises: + ValueError: On non-positive inputs, missing grade constant, or bias + beyond flat-band. + """ + v_bi = built_in_voltage(na_cm3, nd_cm3, temperature_k=temperature_k, ni_cm3=ni_cm3) + _validate_bias(v_reverse, v_bi) + eps_s = _eps_si(permittivity) + + if grading == "linear": + if grade_const_cm4 is None or grade_const_cm4 <= 0: + raise ValueError("grading='linear' requires grade_const_cm4 > 0.") + # a in m^-4 (1 cm^-4 = 1e6 m^-4); W comes out in meters. + a_m4 = grade_const_cm4 * 1e6 + w_m = (12.0 * eps_s * (v_bi + v_reverse) / (Q * a_m4)) ** (1.0 / 3.0) + return float(w_m * 1e6) + + if grading != "abrupt": + raise ValueError(f"Unknown grading type: {grading!r}") + + # Abrupt junction: W = sqrt(2 eps_s (V_bi+VR)/q * (Na+Nd)/(NaNd)). + na_m3 = na_cm3 * 1e6 + nd_m3 = nd_cm3 * 1e6 + w_m = math.sqrt( + 2.0 * eps_s * (v_bi + v_reverse) / Q * (na_m3 + nd_m3) / (na_m3 * nd_m3) + ) + return float(w_m * 1e6) + + +def depletion_extents( + na_cm3: float, + nd_cm3: float, + *, + w_um: float, + grading: Literal["abrupt", "linear"] = "abrupt", +) -> tuple[float, float]: + """Split a total depletion width into P-side/N-side extents in micrometers. + + For an abrupt junction the depletion spills asymmetrically:: + + x_p = W Nd / (Na + Nd), x_n = W Na / (Na + Nd) + + A linearly graded junction is symmetric around the metallurgical + junction, so ``x_p = x_n = W/2``. + + Args: + na_cm3: Acceptor concentration in cm^-3 (> 0). + nd_cm3: Donor concentration in cm^-3 (> 0). + w_um: Total depletion width in micrometers (from + :func:`depletion_width`). + grading: Junction grading type. + + Returns: + ``(xp_um, xn_um)`` — extents spilled into the P and N sides. + """ + if na_cm3 <= 0 or nd_cm3 <= 0: + raise ValueError("Doping concentrations must be positive (cm^-3).") + if w_um < 0: + raise ValueError("w_um must be non-negative.") + if grading == "linear": + return w_um / 2.0, w_um / 2.0 + total = na_cm3 + nd_cm3 + return w_um * nd_cm3 / total, w_um * na_cm3 / total + + +def junction_capacitance_per_area( + permittivity: float, + w_um: float, +) -> float: + """Depletion capacitance per unit area ``C_j = eps_s / W`` in F/m^2. + + Args: + permittivity: Relative permittivity of the semiconductor. + w_um: Total depletion width in micrometers (> 0). + + Returns: + Capacitance per unit area in F/m^2. + """ + if w_um <= 0: + raise ValueError("w_um must be positive.") + return _eps_si(permittivity) / (w_um * 1e-6) + + +def select_junction_mode( + w_um: float, + p_extent_um: float, + n_extent_um: float, + *, + fraction: float = JUNCTION_MODE_FRACTION, +) -> JunctionMode: + """Choose how to represent the depletion region in a simulation. + + The depletion strip is meshed explicitly (``"high_res"``) when its width + is comparable to the doped sections flanking it — specifically when + ``w_um >= fraction * min(p_extent, n_extent)``. Otherwise the region is + far thinner than its neighbours and meshing it would only bloat the + model, so a lumped capacitance boundary is used instead + (``"capacitance"``). + + Args: + w_um: Total depletion width in micrometers (> 0). + p_extent_um: Size of the doped section flanking the junction on the + P side (micrometers, > 0). + n_extent_um: Size of the doped section flanking the junction on the + N side (micrometers, > 0). + fraction: Resolvability threshold as a fraction of the smaller flank + (default ~1/5). + + Returns: + ``"high_res"`` when the geometry should carry the depletion strip, + ``"capacitance"`` otherwise. + """ + if w_um <= 0: + raise ValueError("w_um must be positive.") + if p_extent_um <= 0 or n_extent_um <= 0: + raise ValueError("Flank extents must be positive.") + if not 0 < fraction <= 1: + raise ValueError("fraction must lie in (0, 1].") + threshold_um = fraction * min(p_extent_um, n_extent_um) + return "high_res" if w_um >= threshold_um else "capacitance" + + +class PNJunctionConfig(BaseModel): + """Parameters of a PN-junction depletion model (depletion approximation). + + Concentrations use the semiconductor-industry convention (cm^-3); + derived lengths are exposed in micrometers and capacitances in farads. + See module docstring for the underlying formulas (Sze ch. 2). + + Attributes: + na_cm3: Acceptor concentration on the P side (cm^-3). + nd_cm3: Donor concentration on the N side (cm^-3). + v_reverse: Applied reverse bias in volts (positive = reverse; + negative values model forward bias below flat-band). + temperature_k: Lattice temperature in kelvin. + ni_cm3: Intrinsic carrier concentration (cm^-3). + permittivity: Relative permittivity of the depleted semiconductor. + grading: ``"abrupt"`` or ``"linear"`` junction profile. + grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4, required + when ``grading="linear"``. + """ + + model_config = ConfigDict(validate_assignment=True) + + na_cm3: float = Field(gt=0, description="Acceptor concentration (cm^-3)") + nd_cm3: float = Field(gt=0, description="Donor concentration (cm^-3)") + v_reverse: float = Field( + default=0.0, description="Applied reverse bias [V] (positive = reverse)" + ) + temperature_k: float = Field(default=300.0, gt=0, description="Temperature [K]") + ni_cm3: float = Field( + default=NI_SI_300K_CM3, gt=0, description="Intrinsic carriers (cm^-3)" + ) + permittivity: float = Field( + default=DEFAULT_SI_PERMITTIVITY, + ge=1.0, + description="Relative permittivity of the semiconductor", + ) + grading: Literal["abrupt", "linear"] = Field(default="abrupt") + grade_const_cm4: float | None = Field( + default=None, gt=0, description="Grade constant a = |dN/dx| (cm^-4)" + ) + + @model_validator(mode="after") + def _validate_physics(self) -> Self: + """Check grading configuration and bias range.""" + if self.grading == "linear" and self.grade_const_cm4 is None: + raise ValueError("grading='linear' requires grade_const_cm4.") + _validate_bias(self.v_reverse, self.v_bi) + return self + + @property + def v_bi(self) -> float: + """Built-in potential in volts.""" + return built_in_voltage( + self.na_cm3, + self.nd_cm3, + temperature_k=self.temperature_k, + ni_cm3=self.ni_cm3, + ) + + @property + def w_um(self) -> float: + """Total depletion width in micrometers at the configured bias.""" + return depletion_width( + self.na_cm3, + self.nd_cm3, + v_reverse=self.v_reverse, + temperature_k=self.temperature_k, + ni_cm3=self.ni_cm3, + permittivity=self.permittivity, + grading=self.grading, + grade_const_cm4=self.grade_const_cm4, + ) + + @property + def xp_um(self) -> float: + """Depletion extent spilled into the P side (micrometers).""" + xp, _xn = depletion_extents( + self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading + ) + return xp + + @property + def xn_um(self) -> float: + """Depletion extent spilled into the N side (micrometers).""" + _xp, xn = depletion_extents( + self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading + ) + return xn + + @property + def c_per_area(self) -> float: + """Junction capacitance per unit area in F/m^2 (``eps_s / W``).""" + return junction_capacitance_per_area(self.permittivity, self.w_um) + + def capacitance(self, length_um: float, height_um: float) -> float: + """Absolute junction capacitance for a rectangular junction face. + + Treats the depletion strip as a parallel-plate capacitor of area + ``length x height`` filled with the depleted semiconductor: + ``C = eps_s * A / W``. + + Args: + length_um: Device length along the propagation direction (um). + height_um: Junction z-extent (um), e.g. the rib height. + + Returns: + Absolute capacitance in farads. + """ + if length_um <= 0 or height_um <= 0: + raise ValueError("length_um and height_um must be positive.") + area_m2 = length_um * height_um * 1e-12 + return float(self.c_per_area * area_m2) + + def select_mode( + self, + p_extent_um: float, + n_extent_um: float, + *, + fraction: float = JUNCTION_MODE_FRACTION, + ) -> JunctionMode: + """Auto-select the representation mode for this junction. + + Thin wrapper around :func:`select_junction_mode` using this config's + computed depletion width. + + Args: + p_extent_um: Size of the doped flank on the P side (um). + n_extent_um: Size of the doped flank on the N side (um). + fraction: Resolvability threshold fraction (~1/5 default). + + Returns: + ``"high_res"`` or ``"capacitance"``. + """ + return select_junction_mode( + self.w_um, p_extent_um, n_extent_um, fraction=fraction + ) + + def to_metadata(self) -> dict[str, Any]: + """Return a plain-dict summary of the computed junction quantities.""" + return { + "na_cm3": self.na_cm3, + "nd_cm3": self.nd_cm3, + "v_reverse": self.v_reverse, + "temperature_k": self.temperature_k, + "v_bi": self.v_bi, + "w_um": self.w_um, + "xp_um": self.xp_um, + "xn_um": self.xn_um, + "c_per_area_f_m2": self.c_per_area, + "grading": self.grading, + } diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index b7151138..f1e0d52a 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -427,6 +427,68 @@ def add_impedance_boundary( ) ) + def set_pn_junction( + self, + junction: Any, + *, + layer_p: str, + layer_n: str, + length_um: float, + height_um: float, + name: str | None = None, + ) -> float: + """Apply the depletion capacitance of a PN junction between two layers. + + Capacitance-mode modelling of a PN junction: the depletion width + ``W`` is computed from the doping concentrations and bias point via + :class:`gsim.common.stack.junction.PNJunctionConfig` (Sze, + *Physics of Semiconductor Devices*, ch. 2), converted to an absolute + parallel-plate capacitance ``C = eps_s * A / W``, and applied as a + lumped Impedance boundary on the shared P/N interface. + + Use this when the depletion strip is too thin to resolve on the mesh + (the auto-selection in + :func:`gsim.common.stack.doping.make_pn_junction_profile` picks this + regime); for well-resolved depletion regions prefer drawing them as + dielectric geometry (``mode="high_res"``) instead. + + Args: + junction: ``PNJunctionConfig`` or its dict form (doping + concentrations, bias, temperature, permittivity). + layer_p: Name of the P-doped layer. + layer_n: Name of the N-doped layer. + length_um: Device length along the propagation direction (um). + height_um: Junction z-extent (um), e.g. the rib height. + name: Optional display name for the boundary. + + Returns: + The absolute capacitance applied [F]. + + Example: + >>> sim.set_pn_junction( + ... {"na_cm3": 1e19, "nd_cm3": 1e19}, + ... layer_p="p_rib", + ... layer_n="n_rib", + ... length_um=10.0, + ... height_um=0.22, + ... ) + """ + from gsim.common.stack.junction import PNJunctionConfig + + cfg = ( + junction + if isinstance(junction, PNJunctionConfig) + else PNJunctionConfig.model_validate(junction) + ) + capacitance = cfg.capacitance(length_um=length_um, height_um=height_um) + self.add_impedance_boundary( + layer_p, + layer_n, + capacitance=capacitance, + name=name, + ) + return capacitance + # ------------------------------------------------------------------------- # Material methods # ------------------------------------------------------------------------- diff --git a/tests/common/test_cross_section.py b/tests/common/test_cross_section.py index cfd39260..2bc04e56 100644 --- a/tests/common/test_cross_section.py +++ b/tests/common/test_cross_section.py @@ -387,6 +387,44 @@ def test_builds_stack_with_doping_and_rib_layers(self): layers = {r.layer_name for r in section} assert {"core", "pp_slab_0", "npp_slab_0"} <= layers + def test_doping_materials_registered_on_stack(self): + """Doping/rib materials must land on stack.materials for solver config. + + Regression: the merged materials dict used to be computed but never + attached, so doped domains silently resolved to eps=1.0 without + conductivity in the generated Palace config. + """ + 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, + ) + + for name, sigma in ( + ("pp_slab_0", 2e4), + ("p_rib", 1.6e3), + ("n_rib", 1.6e3), + ): + assert name in stack.materials, f"{name} missing from stack.materials" + props = stack.materials[name] + assert isinstance(props, dict) + assert props["permittivity"] == pytest.approx(11.9) + assert props["conductivity"] == pytest.approx(sigma) + def test_metal1_override_applied(self): comp, _LAYER = self._build_component() stack, _ = build_doped_cross_section( diff --git a/tests/common/test_junction_physics.py b/tests/common/test_junction_physics.py new file mode 100644 index 00000000..1b830b31 --- /dev/null +++ b/tests/common/test_junction_physics.py @@ -0,0 +1,215 @@ +"""Tests for the PN-junction depletion physics (Sze ch. 2 formulas). + +The expected values are recomputed here from the textbook expressions with +scipy.constants so the tests validate the wiring independently of the +implementation internals. +""" + +from __future__ import annotations + +import math + +import pytest +from pydantic import ValidationError +from scipy.constants import Boltzmann as KB # noqa: N814 +from scipy.constants import elementary_charge as Q # noqa: N812 +from scipy.constants import epsilon_0 as EPS0 # noqa: N812 + +from gsim.common.stack.junction import ( + PNJunctionConfig, + built_in_voltage, + depletion_extents, + depletion_width, + junction_capacitance_per_area, + select_junction_mode, +) + +VT_300 = KB * 300.0 / Q + + +class TestBuiltInVoltage: + def test_symmetric_silicon_value(self): + v_bi = built_in_voltage(1e19, 1e19) + expected = VT_300 * math.log(1e38 / (1.5e10) ** 2) + assert v_bi == pytest.approx(expected, rel=1e-12) + assert v_bi == pytest.approx(1.05, abs=0.03) + + def test_temperature_dependence(self): + cold = built_in_voltage(1e18, 1e18, temperature_k=250.0) + hot = built_in_voltage(1e18, 1e18, temperature_k=350.0) + expected_cold = KB * 250.0 / Q * math.log(1e36 / (1.5e10) ** 2) + expected_hot = KB * 350.0 / Q * math.log(1e36 / (1.5e10) ** 2) + assert cold == pytest.approx(expected_cold, rel=1e-12) + assert hot == pytest.approx(expected_hot, rel=1e-12) + + def test_rejects_nonphysical_inputs(self): + with pytest.raises(ValueError): + built_in_voltage(-1e18, 1e18) + with pytest.raises(ValueError): + built_in_voltage(1e18, 0.0) + with pytest.raises(ValueError): + built_in_voltage(1e18, 1e18, temperature_k=0.0) + + def test_rejects_degenerate_doping(self): + with pytest.raises(ValueError, match="ni"): + built_in_voltage(1e9, 1e9) + + +class TestDepletionWidthAbrupt: + def test_symmetric_hand_check(self): + w_um = depletion_width(1e18, 1e18) + na_m3 = nd_m3 = 1e18 * 1e6 + eps_s = 11.9 * EPS0 + expected_m = math.sqrt( + 2 + * eps_s + * VT_300 + * math.log(1e36 / (1.5e10) ** 2) + / Q + * (na_m3 + nd_m3) + / (na_m3 * nd_m3) + ) + assert w_um == pytest.approx(expected_m * 1e6, rel=1e-12) + + def test_reverse_bias_sqrt_scaling(self): + w0 = depletion_width(1e19, 5e17) + vbi = built_in_voltage(1e19, 5e17) + w_r = depletion_width(1e19, 5e17, v_reverse=2.0) + assert w_r / w0 == pytest.approx(math.sqrt((vbi + 2.0) / vbi), rel=1e-12) + + def test_one_sided_limit(self): + # NA >> ND: nearly all the depletion spills into the lightly doped side. + w = depletion_width(1e20, 1e17) + xp, xn = depletion_extents(1e20, 1e17, w_um=w) + assert xn == pytest.approx(w, rel=1e-3) + assert xp == pytest.approx(w * 1e-3, rel=1e-2) + + def test_forward_bias_below_flatband(self): + vbi = built_in_voltage(1e18, 1e18) + w_eq = depletion_width(1e18, 1e18) + w_fw = depletion_width(1e18, 1e18, v_reverse=-vbi / 2) + assert w_fw < w_eq + with pytest.raises(ValueError, match="flat-band"): + depletion_width(1e18, 1e18, v_reverse=-(vbi + 0.01)) + + +class TestDepletionWidthGraded: + def test_cubic_root_law(self): + a_cm4 = 1e21 + vbi = built_in_voltage(1e18, 1e18) + w = depletion_width(1e18, 1e18, grading="linear", grade_const_cm4=a_cm4) + eps_s = 11.9 * EPS0 + expected_m = (12 * eps_s * vbi / (Q * a_cm4 * 1e6)) ** (1 / 3) + assert w == pytest.approx(expected_m * 1e6, rel=1e-12) + + def test_graded_bias_scaling(self): + kwargs = dict(grading="linear", grade_const_cm4=1e21) + w0 = depletion_width(1e18, 1e18, **kwargs) + w_r = depletion_width(1e18, 1e18, v_reverse=1.0, **kwargs) + vbi = built_in_voltage(1e18, 1e18) + assert w_r / w0 == pytest.approx(((vbi + 1.0) / vbi) ** (1 / 3), rel=1e-12) + + def test_graded_is_symmetric(self): + w = depletion_width(1e18, 1e19, grading="linear", grade_const_cm4=1e20) + xp, xn = depletion_extents(1e18, 1e19, w_um=w, grading="linear") + assert xp == pytest.approx(w / 2) + assert xn == pytest.approx(w / 2) + + def test_requires_grade_constant(self): + with pytest.raises(ValueError, match="grade_const"): + depletion_width(1e18, 1e18, grading="linear") + + def test_unknown_grading(self): + with pytest.raises(ValueError, match="grading"): + depletion_width(1e18, 1e18, grading="exponential") # type: ignore[arg-type] + + +class TestCapacitance: + def test_per_area_inverse_w(self): + eps_r = 11.9 + for w_um in (0.01, 0.05, 0.2): + c = junction_capacitance_per_area(eps_r, w_um) + assert c == pytest.approx(eps_r * EPS0 / (w_um * 1e-6), rel=1e-12) + + def test_absolute_capacitance(self): + junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + c = junc.capacitance(length_um=10.0, height_um=0.22) + area_m2 = 10.0 * 0.22 * 1e-12 + assert c == pytest.approx(junc.c_per_area * area_m2, rel=1e-12) + # Same order as typical TW-MZM junction caps (~fF per 10 um). + assert 1e-15 < c < 1e-13 + + def test_capacitance_scales_with_bias(self): + junc0 = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + junc_r = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=3.0) + assert junc_r.capacitance(10.0, 0.22) < junc0.capacitance(10.0, 0.22) + + +class TestSelectJunctionMode: + def test_comparable_width_selects_high_res(self): + assert select_junction_mode(0.05, 0.2, 0.2) == "high_res" + assert select_junction_mode(0.0401, 0.2, 0.2) == "high_res" + + def test_too_thin_selects_capacitance(self): + assert select_junction_mode(0.0166, 0.2, 0.2) == "capacitance" + assert select_junction_mode(0.0399, 0.2, 0.2) == "capacitance" + + def test_threshold_is_fraction_of_smaller_flank(self): + assert select_junction_mode(0.0099, 0.05, 0.4, fraction=0.2) == "capacitance" + assert select_junction_mode(0.0101, 0.05, 0.4, fraction=0.2) == "high_res" + + def test_custom_fraction(self): + assert select_junction_mode(0.09, 0.2, 0.2, fraction=0.5) == "capacitance" + assert select_junction_mode(0.11, 0.2, 0.2, fraction=0.5) == "high_res" + + def test_invalid_inputs(self): + with pytest.raises(ValueError): + select_junction_mode(0.0, 0.2, 0.2) + with pytest.raises(ValueError): + select_junction_mode(0.1, 0.0, 0.2) + with pytest.raises(ValueError): + select_junction_mode(0.1, 0.2, 0.2, fraction=1.5) + + +class TestPNJunctionConfig: + def test_derived_quantities_consistent(self): + cfg = PNJunctionConfig(na_cm3=2e18, nd_cm3=8e18, v_reverse=0.5) + assert cfg.v_bi == pytest.approx(built_in_voltage(2e18, 8e18)) + assert cfg.w_um == pytest.approx( + depletion_width(2e18, 8e18, v_reverse=0.5), rel=1e-12 + ) + total = cfg.xp_um + cfg.xn_um + assert total == pytest.approx(cfg.w_um, rel=1e-12) + # Asymmetric split: more depletion on the lighter-doped side. + assert cfg.xp_um > cfg.xn_um + + def test_dict_construction(self): + cfg = PNJunctionConfig.model_validate({"na_cm3": 1e19, "nd_cm3": 1e19}) + assert cfg.na_cm3 == 1e19 + + def test_linear_requires_grade_const(self): + with pytest.raises(ValidationError, match="grade_const"): + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, grading="linear") + + def test_rejects_beyond_flatband(self): + vbi = built_in_voltage(1e18, 1e18) + with pytest.raises(ValidationError): + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=-vbi - 0.05) + + def test_rejects_bad_concentrations(self): + with pytest.raises(ValidationError): + PNJunctionConfig(na_cm3=0.0, nd_cm3=1e18) + + def test_to_metadata_keys(self): + meta = PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18).to_metadata() + for key in ( + "na_cm3", + "nd_cm3", + "v_bi", + "w_um", + "xp_um", + "xn_um", + "c_per_area_f_m2", + "grading", + ): + assert key in meta diff --git a/tests/common/test_junction_profile.py b/tests/common/test_junction_profile.py new file mode 100644 index 00000000..29061113 --- /dev/null +++ b/tests/common/test_junction_profile.py @@ -0,0 +1,180 @@ +"""Tests for ``make_pn_junction_profile`` geometry, materials and mode selection.""" + +from __future__ import annotations + +import logging + +import gdsfactory as gf +import pytest + +from gsim.common.cross_section import extract_plane_section +from gsim.common.stack.doping import make_pn_junction_profile +from gsim.common.stack.extractor import LayerStack +from gsim.common.stack.junction import PNJunctionConfig + +CY = -20.0 +RIB_WIDTH = 0.4 +LENGTH = 10.0 + +P_REGION = ("p_rib", (21, 0), 1.6e3) +N_REGION = ("n_rib", (20, 0), 1.6e3) +JUNCTION_REGION = ("junction", (22, 0)) + + +def _thin_junction() -> PNJunctionConfig: + """Na=Nd=1e19 cm^-3 at zero bias -> W ~ 17 nm < threshold.""" + return PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + + +def _wide_junction() -> PNJunctionConfig: + """Light doping + reverse bias -> W ~ 71 nm > threshold (40 nm).""" + return PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=1.0) + + +def _build(junction, **kwargs): + comp = gf.Component() + kwargs.setdefault("p_region", P_REGION) + kwargs.setdefault("n_region", N_REGION) + kwargs.setdefault("zmin", 0.0) + kwargs.setdefault("zmax", 0.22) + result = make_pn_junction_profile( + comp, + length=LENGTH, + center_y=CY, + rib_width=RIB_WIDTH, + junction=junction, + **kwargs, + ) + return comp, result + + +def _section_rects(comp, result): + """Extract the x=0 plane section from a profile-built component.""" + stack = LayerStack(pdk_name="test") + stack.layers.update(result["layer_specs"]) + for name, mat in result["materials"].items(): + stack.materials[name] = mat.to_dict() + rects = extract_plane_section(comp.copy(), stack, axis="x", value=0.0) + return sorted(rects, key=lambda r: r.y0) + + +class TestAutoModeSelection: + def test_thin_junction_selects_capacitance(self): + _comp, res = _build(_thin_junction()) + assert res["junction"]["mode"] == "capacitance" + assert "threshold" in res["junction"]["selection_reason"] + + def test_wide_junction_selects_high_res(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + assert res["junction"]["mode"] == "high_res" + + def test_auto_logs_selection_reason(self, caplog): + with caplog.at_level(logging.INFO, logger="gsim.common.stack.doping"): + _comp, _res = _build(_thin_junction()) + assert any("capacitance" in rec.message for rec in caplog.records) + + def test_forced_mode_overrides_auto(self): + _comp, res = _build( + _thin_junction(), mode="high_res", junction_region=JUNCTION_REGION + ) + assert res["junction"]["mode"] == "high_res" + assert "forced" in res["junction"]["selection_reason"] + _comp, res = _build(_wide_junction(), mode="capacitance") + assert res["junction"]["mode"] == "capacitance" + + +class TestCapacitanceModeGeometry: + def test_no_junction_polygon_or_spec(self): + comp, res = _build(_thin_junction()) + assert "junction" not in res["layer_specs"] + assert "junction" not in res["materials"] + # No polygon may exist on the junction GDS layer. + polys = comp.get_polygons(layers=(JUNCTION_REGION[1],)) + assert not any(v for v in polys.values()) + + def test_p_n_adjacent_halves(self): + comp, res = _build(_thin_junction()) + rects = _section_rects(comp, res) + names = [r.layer_name for r in rects] + assert set(names) == {"p_rib", "n_rib"} + by_name = {r.layer_name: r for r in rects} + assert by_name["p_rib"].y0 == pytest.approx(CY) + assert by_name["n_rib"].y1 == pytest.approx(CY) + + def test_junction_metadata_present(self): + junc = _thin_junction() + _comp, res = _build(junc) + meta = res["junction"] + assert meta["w_um"] == pytest.approx(junc.w_um) + assert meta["c_f"] == pytest.approx(junc.capacitance(LENGTH, 0.22)) + assert meta["xp_um"] + meta["xn_um"] == pytest.approx(meta["w_um"]) + + +class TestHighResModeGeometry: + def test_three_contiguous_regions(self): + comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + rects = _section_rects(comp, res) + names = [r.layer_name for r in rects] + assert names == ["n_rib", "junction", "p_rib"] + + n_r, j_r, p_r = rects + # Contiguity with no gaps or overlaps. + assert n_r.y1 == pytest.approx(j_r.y0) + assert j_r.y1 == pytest.approx(p_r.y0) + + junc = _wide_junction() + # Depletion strip spans [cy - xn, cy + xp] (within layout DBU rounding). + assert j_r.y0 == pytest.approx(CY - junc.xn_um, abs=2e-3) + assert j_r.y1 == pytest.approx(CY + junc.xp_um, abs=2e-3) + assert (j_r.y1 - j_r.y0) == pytest.approx(junc.w_um, abs=4e-3) + # Flanks fill the rest of the rib. + assert (p_r.y1 - p_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xp_um, abs=4e-3) + assert (n_r.y1 - n_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xn_um, abs=4e-3) + # Full rib span is covered exactly once. + assert p_r.y1 - n_r.y0 == pytest.approx(RIB_WIDTH) + + def test_material_models(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + # Doped regions carry Drude conductivity. + for name in ("p_rib", "n_rib"): + mat = res["materials"][name] + assert mat.conductivity == pytest.approx(1.6e3) + assert mat.permittivity == pytest.approx(11.9) + # Junction strip: depleted silicon -> pure real permittivity, no carriers. + jmat = res["materials"]["junction"] + assert jmat.permittivity == pytest.approx(11.9) + assert jmat.conductivity is None + assert jmat.dispersion_models == [] + + def test_high_res_requires_junction_region(self): + with pytest.raises(ValueError, match="junction_region"): + _build(_wide_junction()) + + def test_layer_specs_reference_materials(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + for name in ("p_rib", "n_rib", "junction"): + spec = res["layer_specs"][name] + assert spec.material == name + assert spec.zmin == 0.0 + assert spec.zmax == 0.22 + + +class TestValidation: + def test_depletion_wider_than_rib_rejected(self): + big = PNJunctionConfig(na_cm3=1e16, nd_cm3=1e16, v_reverse=5.0) + if big.w_um <= RIB_WIDTH: + pytest.skip("picked parameters do not exceed the rib width") + with pytest.raises(ValueError, match="fit"): + _build(big) + + def test_invalid_zmax_rejected(self): + with pytest.raises(ValueError): + _build(_thin_junction(), zmax=-1.0) + + def test_accepts_dict_junction_config(self): + _comp, res = _build( + {"na_cm3": 1e18, "nd_cm3": 1e18, "v_reverse": 1.0}, + junction_region=JUNCTION_REGION, + ) + assert res["junction"]["w_um"] == pytest.approx(_wide_junction().w_um) + assert res["junction"]["mode"] == "high_res" diff --git a/tests/palace/test_pn_junction_modes.py b/tests/palace/test_pn_junction_modes.py new file mode 100644 index 00000000..6c691946 --- /dev/null +++ b/tests/palace/test_pn_junction_modes.py @@ -0,0 +1,159 @@ +"""End-to-end tests: PN-junction capacitance vs high-res mesh representation. + +Capacitance mode must produce a Palace ``Boundaries.Impedance`` entry with +``Cs = C / interface_length`` and no junction domain. High-res mode must +produce a ``junction`` dielectric domain (pure real permittivity) and no +Impedance boundary. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import gdsfactory as gf +import pytest + +from gsim.common.cross_section import build_doped_cross_section +from gsim.common.stack.doping import make_pn_junction_profile +from gsim.common.stack.junction import PNJunctionConfig +from gsim.palace import BoundaryModeSim + +F_RF = 50e9 + + +def _thin_junction() -> PNJunctionConfig: + """W ~ 17 nm -> below the auto threshold -> capacitance mode.""" + return PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + + +def _wide_junction() -> PNJunctionConfig: + """W ~ 71 nm -> above the auto threshold -> high-res mode.""" + return PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=1.0) + + +def _build_device(junction: PNJunctionConfig, **profile_kwargs): + """Build the rib+slab+doping device and return (comp, stack).""" + gf.gpdk.PDK.activate() + comp = gf.Component() + wg = comp << gf.c.rectangle((10.0, 0.4), centered=True, layer=(1, 0)) + wg.y = -20.0 + slab = comp << gf.c.rectangle((10.0, 100.0), centered=True, layer=(3, 0)) + slab.y = -5.0 + + pn = make_pn_junction_profile( + comp, + length=10.0, + center_y=-20.0, + rib_width=0.4, + junction=junction, + p_region=("p_rib", (21, 0), 1.6e3), + n_region=("n_rib", (20, 0), 1.6e3), + junction_region=("junction", (22, 0)), + zmin=0.0, + zmax=0.22, + **profile_kwargs, + ) + stack, _section = build_doped_cross_section( + comp, + axis="x", + value=0.0, + substrate_thickness=2.0, + doping=pn, + verbose=False, + ) + return comp, stack, pn + + +def _make_sim(junction: PNJunctionConfig, tmp_path: Path, apply_capacitance: bool): + comp, stack, pn = _build_device(junction) + sim = BoundaryModeSim() + sim.set_output_dir(str(tmp_path / "palace-sim-pn")) + sim.set_stack(stack) + sim.set_airbox(margin_x=3.0, margin_y=3.0, z_above=2.0, z_below=2.0) + sim.set_geometry(comp) + sim.set_cross_section("x=0") + sim.set_boundary_mode(freq=F_RF, num_modes=1, save=0) + sim.mesh(preset="coarse", refined_mesh_size=0.05, max_mesh_size=40.0) + if apply_capacitance: + applied = sim.set_pn_junction( + junction, + layer_p="p_rib", + layer_n="n_rib", + length_um=10.0, + height_um=0.22, + ) + assert applied == pytest.approx(junction.capacitance(10.0, 0.22)) + sim.write_config() + config_path = Path(sim.output_dir) / "config.json" + return sim, json.loads(config_path.read_text()), pn + + +@pytest.fixture(scope="module") +def cap_mode(tmp_path_factory): + """Thin depletion: auto-selected capacitance mode with lumped C.""" + return _make_sim(_thin_junction(), tmp_path_factory.mktemp("cap"), True) + + +@pytest.fixture(scope="module") +def hires_mode(tmp_path_factory): + """Wide depletion: auto-selected high-res mode, no lumped C.""" + return _make_sim(_wide_junction(), tmp_path_factory.mktemp("hires"), False) + + +class TestCapacitanceMode: + def test_no_junction_domain_on_mesh(self, cap_mode): + sim, _config, _pn = cap_mode + groups = sim._last_mesh_result.groups + assert "junction" not in groups["volumes"] + + def test_impedance_boundary_in_config(self, cap_mode): + _sim, config, _pn = cap_mode + impedance = config.get("Boundaries", {}).get("Impedance", []) + assert len(impedance) == 1 + assert "Cs" in impedance[0] + assert impedance[0]["Cs"] > 0 + + def test_cs_value_matches_computed_capacitance(self, cap_mode): + _sim, config, pn = cap_mode + # Interface p_rib|n_rib is the vertical rib edge; its curve length is + # the 0.22 um rib height, so Cs = C / 0.22um. + expected_cs = pn["junction"]["c_f"] / (0.22 * 1e-6) + cs = config["Boundaries"]["Impedance"][0]["Cs"] + assert cs == pytest.approx(expected_cs, rel=1e-9) + + def test_doped_domains_present(self, cap_mode): + sim, _config, _pn = cap_mode + groups = sim._last_mesh_result.groups + assert {"p_rib", "n_rib"} <= set(groups["volumes"]) + + +class TestHighResMode: + def test_junction_dielectric_domain_on_mesh(self, hires_mode): + sim, _config, _pn = hires_mode + groups = sim._last_mesh_result.groups + assert "junction" in groups["volumes"] + assert groups["volumes"]["junction"].get("is_shaped_dielectric") is True + + def test_no_impedance_boundary(self, hires_mode): + _sim, config, _pn = hires_mode + assert not config.get("Boundaries", {}).get("Impedance") + + def test_junction_material_is_pure_dielectric(self, hires_mode): + sim, config, _pn = hires_mode + groups = sim._last_mesh_result.groups + junc_attr = groups["volumes"]["junction"]["phys_group"] + materials = config["Domains"]["Materials"] + entries = [m for m in materials if junc_attr in m.get("Attributes", [])] + assert len(entries) == 1, f"Expected one material for attr {junc_attr}" + entry = entries[0] + assert abs(float(entry["Permittivity"]) - 11.9) < 1e-6 + assert not entry.get("Conductivity"), ( + "Depleted silicon must have zero conductivity" + ) + + def test_p_n_junction_strip_contiguous(self, hires_mode): + """All three regions survive as separate domains.""" + sim, _config, _pn = hires_mode + volumes = sim._last_mesh_result.groups["volumes"] + assert {"p_rib", "n_rib", "junction"} <= set(volumes) From d410f7123b7f6b90e9f3bc03123e7495bda32942 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Wed, 26 Aug 2026 00:52:18 -0300 Subject: [PATCH 05/14] demo: default junction doping 1e18 cm-3 (high-res mode), expose T and ni --- nbs/palace_2d_twmzm.ipynb | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index 0806ecc9..b0dbf463 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -83,14 +83,25 @@ "\n", "# --- PN junction depletion model (Sze ch. 2) ---------------------------------\n", "# W = sqrt(2 eps_s (V_bi + V_R)/q * (Na+Nd)/(Na Nd)); x_p/x_n split the\n", - "# depletion into the P/N sides; C_j = eps_s A / W. Doping in cm^-3.\n", + "# depletion into the P/N sides; C_j = eps_s A / W.\n", + "#\n", + "# All model inputs are user-selectable here: doping concentrations (cm^-3),\n", + "# reverse bias V_R [V] (positive = reverse), lattice temperature [K],\n", + "# intrinsic carrier concentration ni [cm^-3], and permittivity. The values\n", + "# below are typical for a Si-photonic modulator junction (~1e18 cm^-3 near\n", + "# the metallurgical junction; contacts are handled by the graded slab\n", + "# doping at ~1e20 cm^-3). At this doping W ~ 50 nm is resolvable on the\n", + "# mesh, so auto-selection picks high-res mode.\n", "SI_PERMITTIVITY = 11.9\n", "FMAX_RF_MATERIAL = 200e9 # validity range of the constant-eps doping models (Hz)\n", - "PN_RIB_SIGMA = 1.6e3 # Drude conductivity of the P/N rib regions (S/m)\n", + "PN_RIB_SIGMA = 1.6e3 # Drude conductivity of the P/N rib regions (S/m),\n", + "# sigma = q*mu*N with mu ~ 1000 cm^2/Vs at N ~ 1e18\n", "PN_JUNCTION = {\n", - " \"na_cm3\": 1e19,\n", - " \"nd_cm3\": 1e19,\n", + " \"na_cm3\": 1e18,\n", + " \"nd_cm3\": 1e18,\n", " \"v_reverse\": 0.0,\n", + " \"temperature_k\": 300.0,\n", + " \"ni_cm3\": 1.5e10,\n", " \"permittivity\": SI_PERMITTIVITY,\n", "}\n", "JUNCTION_GDS_LAYER = (22, 0) # depletion-strip GDS layer (drawn only in high-res)\n", From a152263ac48fedacdcbd51f3eb6c2071962853ca Mon Sep 17 00:00:00 2001 From: mdmaas Date: Wed, 26 Aug 2026 00:59:09 -0300 Subject: [PATCH 06/14] fix: narrow plane-section union in junction profile test for latest ty --- tests/common/test_junction_profile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/common/test_junction_profile.py b/tests/common/test_junction_profile.py index 29061113..9a5f3814 100644 --- a/tests/common/test_junction_profile.py +++ b/tests/common/test_junction_profile.py @@ -3,11 +3,12 @@ from __future__ import annotations import logging +from typing import cast import gdsfactory as gf import pytest -from gsim.common.cross_section import extract_plane_section +from gsim.common.cross_section import RectYZ2D, extract_plane_section from gsim.common.stack.doping import make_pn_junction_profile from gsim.common.stack.extractor import LayerStack from gsim.common.stack.junction import PNJunctionConfig @@ -55,7 +56,8 @@ def _section_rects(comp, result): for name, mat in result["materials"].items(): stack.materials[name] = mat.to_dict() rects = extract_plane_section(comp.copy(), stack, axis="x", value=0.0) - return sorted(rects, key=lambda r: r.y0) + # axis="x" always yields YZ rectangles; narrow the union for attribute access. + return sorted(cast("list[RectYZ2D]", rects), key=lambda r: r.y0) class TestAutoModeSelection: From 5cafb8b71157bf3ad438485b278b0f389ebbe3f0 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Thu, 10 Sep 2026 16:27:44 -0300 Subject: [PATCH 07/14] feat(pn-junction): 1D Sze permittivity, segmented optical waveguide Consolidate stack/junction.py + stack/doping.py into stack/pn_junction.py and the three pn-junction test files into tests/common/test_pn_junction.py. Add the 1D free-carrier plasma-dispersion model (carrier_profile_1d, epsilon_eff_relative, optical_params, junction_epsilon_profile) with SI-correct units, plus make_segmented_junction_profile (8+8 uniform p_1..n_8 strips sampling the Sze permittivity at strip centres). The palace_2d_twmzm optical run now uses the segmented rib with per-strip free-carrier materials (Re(eps)->Permittivity, Im(eps)->Conductivity); build_optical_cross_section() accepts per-region device_materials and extra_materials to support it. --- CHANGELOG.md | 11 + docs/api/common.md | 44 +- nbs/palace_2d_twmzm.ipynb | 261 +++-- src/gsim/common/cross_section.py | 34 +- src/gsim/common/stack/__init__.py | 47 +- src/gsim/common/stack/doping.py | 384 ------- src/gsim/common/stack/junction.py | 439 -------- src/gsim/common/stack/pn_junction.py | 1289 ++++++++++++++++++++++++ src/gsim/palace/base.py | 6 +- tests/common/test_cross_section.py | 2 +- tests/common/test_junction_physics.py | 215 ---- tests/common/test_junction_profile.py | 182 ---- tests/common/test_pn_junction.py | 881 ++++++++++++++++ tests/palace/test_pn_junction_modes.py | 159 --- 14 files changed, 2486 insertions(+), 1468 deletions(-) delete mode 100644 src/gsim/common/stack/doping.py delete mode 100644 src/gsim/common/stack/junction.py create mode 100644 src/gsim/common/stack/pn_junction.py delete mode 100644 tests/common/test_junction_physics.py delete mode 100644 tests/common/test_junction_profile.py create mode 100644 tests/common/test_pn_junction.py delete mode 100644 tests/palace/test_pn_junction_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2574bc8d..a61fcda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Impedance boundary via `sim.set_pn_junction()`. The 2D TWMZM demo now illustrates both modes. - Fix: `build_doped_cross_section()` now registers doping/rib materials on `stack.materials`; previously doped domains silently resolved to eps=1.0 without conductivity in generated Palace configs. +- Consolidation: `common/stack/junction.py` + `common/stack/doping.py` merged into `common/stack/pn_junction.py`; + `test_junction_physics.py`, `test_junction_profile.py` and `test_pn_junction_modes.py` merged into + `tests/common/test_pn_junction.py`. Import from `gsim.common.stack.pn_junction` (re-exported at `gsim.common.stack`). +- 1D Sze-based complex permittivity (`carrier_profile_1d`, `epsilon_eff_relative`, `optical_params`, + `junction_epsilon_profile`): depletion-approximation carrier profile plus full Drude plasma dispersion at optical + wavelengths, with `Re(eps) -> Permittivity` / `Im(eps) -> Conductivity` mapping for Palace. At `1e18 cm^-3` the + quasi-neutral rib carries `Δn ≈ -1e-3` (`σ ≈ 0.5 S/m`) while the depletion slice stays at the Sellmeier background. +- Segmented optical junction (`make_segmented_junction_profile`): bins each rib half into uniform strips + (`p_1..p_N`/`n_1..n_N`, junction-outward), each sampling the 1D permittivity at its centre. The 2D TWMZM demo's + optical run now uses 8+8 strips instead of a homogeneous body; `build_optical_cross_section()` accepts per-region + `device_materials`/`extra_materials` to support it. ## 0.1.0 diff --git a/docs/api/common.md b/docs/api/common.md index 514e65d1..26413d33 100644 --- a/docs/api/common.md +++ b/docs/api/common.md @@ -40,7 +40,49 @@ ## PN Junction -Depletion model after Sze & Ng, *Physics of Semiconductor Devices*, ch. 2. +Depletion model after Sze & Ng, *Physics of Semiconductor Devices*, ch. 2, +plus the 1D free-carrier plasma-dispersion model for the complex optical +permittivity and the doping-profile geometry builders. + +::: gsim.common.stack.PNJunctionConfig + options: + show_source: false + +::: gsim.common.stack.make_pn_junction_profile + options: + show_source: false + +::: gsim.common.stack.make_segmented_junction_profile + options: + show_source: false + +::: gsim.common.stack.make_doping_profile + options: + show_source: false + +::: gsim.common.stack.junction_epsilon_profile + options: + show_source: false + +::: gsim.common.stack.carrier_profile_1d + options: + show_source: false + +::: gsim.common.stack.epsilon_eff_relative + options: + show_source: false + +::: gsim.common.stack.optical_params + options: + show_source: false + +::: gsim.common.stack.refractive_index + options: + show_source: false + +::: gsim.common.stack.drude_relaxation_times + options: + show_source: false ::: gsim.common.stack.PNJunctionConfig options: diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index b0dbf463..4e461f1a 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -106,6 +106,18 @@ "}\n", "JUNCTION_GDS_LAYER = (22, 0) # depletion-strip GDS layer (drawn only in high-res)\n", "\n", + "# --- Segmented optical junction (Sze 1D permittivity, 8+8 uniform strips) ---\n", + "# The rib halves are binned into N_P/N_N strips (p_1.. at the metallurgical\n", + "# junction, outward). Each strip samples the Drude plasma-dispersion model at\n", + "# its centre, so the optical mode sees the laterally varying free-carrier\n", + "# permittivity instead of a homogeneous body. Keep OPT_WAVELENGTH_UM in sync\n", + "# with LAMBDA_OPT used by the optical run below.\n", + "N_P_STRIPS = 8 # P-side strip count\n", + "N_N_STRIPS = 8 # N-side strip count\n", + "OPT_WAVELENGTH_UM = 1.55 # free-carrier evaluation wavelength (um)\n", + "OPT_MU_N = 1000.0 # electron mobility in cm^2/(V s) (Sze)\n", + "OPT_MU_P = 450.0 # hole mobility in cm^2/(V s) (Sze)\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", @@ -156,7 +168,7 @@ "metadata": {}, "outputs": [], "source": [ - "from gsim.common.stack.junction import PNJunctionConfig\n", + "from gsim.common.stack.pn_junction import PNJunctionConfig\n", "\n", "junc = PNJunctionConfig.model_validate(PN_JUNCTION)\n", "flank = RIB_WIDTH / 2\n", @@ -204,7 +216,11 @@ "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, make_pn_junction_profile\n", + "from gsim.common.stack.pn_junction import (\n", + " make_doping_profile,\n", + " make_pn_junction_profile,\n", + " make_segmented_junction_profile,\n", + ")\n", "\n", "gf.gpdk.PDK.activate()\n", "\n", @@ -293,21 +309,40 @@ "\n", "\n", "def _build_optical_component() -> tuple[gf.Component, dict]:\n", - " \"\"\"Optical-only cross-section: rib + slab + PN junction (all silicon).\n", + " \"\"\"Optical-only cross-section: rib + slab + segmented PN junction.\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", + " No electrodes, vias, or graded doping. The rib is binned into\n", + " N_P + N_N strips (p_1..p_8 / n_1..n_8, junction-outward), each sampling\n", + " the Sze 1D complex permittivity at its centre via\n", + " ``make_segmented_junction_profile()``.\n", " \"\"\"\n", " comp = gf.Component()\n", - " pn_result = _add_device_core(comp)\n", - " return comp, pn_result\n", + " wg = comp << centered_rect(LENGTH, RIB_WIDTH, LAYER.WG)\n", + " wg.y = RIB_CENTER_Y\n", + " slab = comp << centered_rect(LENGTH, 2 * SLAB_HALF + RIB_WIDTH, LAYER.SLAB90)\n", + " slab.y = 0.0\n", + " seg_result = make_segmented_junction_profile(\n", + " comp,\n", + " length=LENGTH,\n", + " center_y=RIB_CENTER_Y,\n", + " rib_width=RIB_WIDTH,\n", + " junction=PN_JUNCTION,\n", + " n_p=N_P_STRIPS,\n", + " n_n=N_N_STRIPS,\n", + " wavelength_um=OPT_WAVELENGTH_UM,\n", + " mu_n_cm2_vs=OPT_MU_N,\n", + " mu_p_cm2_vs=OPT_MU_P,\n", + " zmin=0.0,\n", + " zmax=RIB_HEIGHT,\n", + " )\n", + " return comp, seg_result\n", "\n", "\n", "# --- RF component (electrodes, vias, graded doping) ---------------------------\n", "comp, doping_result, pn_result = _build_rf_component()\n", "\n", "# --- Optical-only component ----------------------------------------------------\n", - "comp_optical, pn_result_optical = _build_optical_component()" + "comp_optical, seg_result_optical = _build_optical_component()" ] }, { @@ -367,15 +402,16 @@ "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", + "The optical analysis uses a simplified component: rib + slab plus a\n", + "**segmented** PN junction, but **no** electrodes, vias, or graded doping. Each\n", + "rib half is binned into uniform strips (`p_1..p_8` / `n_1..n_8`,\n", + "junction-outward), and every strip maps to its own free-carrier material, so\n", + "the optical mode sees the laterally varying Sze permittivity in a uniform SiO2\n", + "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$.\n", - "When the auto-selected PN representation is high-res, the depletion strip is\n", - "included as an extra silicon region.\n" + "minimal all-dielectric `LayerStack` (with per-region `device_materials` and\n", + "`extra_materials` for the strips) and extracts the 2D cross-section at $x=0$.\n" ] }, { @@ -388,44 +424,88 @@ "# Uniform SiO2 cladding height above z=0 (um)\n", "OPT_CLAD_TOP = 3.0\n", "\n", + "# Segmented rib: one device region per strip, each mapped to its own\n", + "# free-carrier material; core/slab stay plain silicon.\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", - "if \"junction\" in pn_result_optical[\"layer_specs\"]:\n", - " # High-res mode drew the depletion strip; it is plain silicon optically.\n", - " device_layers[\"junction\"] = (JUNCTION_GDS_LAYER, 0.0, RIB_HEIGHT)\n", + "device_materials = {}\n", + "extra_materials = {}\n", + "for _name, _spec in seg_result_optical[\"layer_specs\"].items():\n", + " device_layers[_name] = (tuple(_spec.gds_layer), 0.0, RIB_HEIGHT)\n", + " device_materials[_name] = _name\n", + " extra_materials[_name] = seg_result_optical[\"materials\"][_name]\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=device_layers,\n", + " device_materials=device_materials,\n", + " extra_materials=extra_materials,\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)" + "print(\"Optical cladding:\", stack_opt.dielectrics)\n", + "print(\"Segmented strips:\", sorted(seg_result_optical[\"segments\"]))" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "11", "metadata": {}, + "outputs": [], + "source": [ + "# --- 1D Sze permittivity check: index shift / loss across the rib ------\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from gsim.common.stack.pn_junction import junction_epsilon_profile\n", + "\n", + "y_fine = np.linspace(RIB_CENTER_Y - RIB_WIDTH / 2, RIB_CENTER_Y + RIB_WIDTH / 2, 401)\n", + "prof = junction_epsilon_profile(\n", + " y_fine,\n", + " PN_JUNCTION,\n", + " center_um=RIB_CENTER_Y,\n", + " wavelength_um=OPT_WAVELENGTH_UM,\n", + " mu_n_cm2_vs=OPT_MU_N,\n", + " mu_p_cm2_vs=OPT_MU_P,\n", + ")\n", + "n_bg = float(np.sqrt(prof[\"eps_bg_rel\"]))\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(6, 5), sharex=True)\n", + "ax1.plot(y_fine, np.asarray(prof[\"n_index\"]) - n_bg, label=\"$\\\\Delta n$\")\n", + "for _seg in seg_result_optical[\"segments\"].values():\n", + " ax1.axvspan(_seg[\"y0_um\"], _seg[\"y1_um\"], color=\"gray\", alpha=0.08)\n", + "ax1.set_ylabel(\"$\\\\Delta n$\")\n", + "ax1.legend()\n", + "ax2.plot(y_fine, prof[\"k_index\"], color=\"red\", label=\"$k$\")\n", + "ax2.set_ylabel(\"$k$\")\n", + "ax2.set_xlabel(\"$y$ [um]\")\n", + "ax2.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "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." + "The rib shows the 16 free-carrier strips (light = depleted near the junction,\n", + "dark = quasi-neutral bulk); slab and core stay plain silicon." ] }, { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -434,15 +514,33 @@ "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", + "# Light (depleted, near junction) -> dark (quasi-neutral bulk) per side.\n", + "P_BLUES = [\n", + " \"#deebf7\",\n", + " \"#c6dbef\",\n", + " \"#9ecae1\",\n", + " \"#6baed6\",\n", + " \"#4292c6\",\n", + " \"#2171b5\",\n", + " \"#08519c\",\n", + " \"#08306b\",\n", + "]\n", + "N_GREENS = [\n", + " \"#e5f5e0\",\n", + " \"#c7e9c0\",\n", + " \"#a1d99b\",\n", + " \"#74c476\",\n", + " \"#41ab5d\",\n", + " \"#238b45\",\n", + " \"#006d2c\",\n", + " \"#00441b\",\n", + "]\n", + "PLOT_COLORS_OPT = {\"core\": \"#c0392b\", \"slab\": \"#e67e22\"} # rib/slab Si\n", + "for _i in range(1, N_P_STRIPS + 1):\n", + " PLOT_COLORS_OPT[f\"p_{_i}\"] = P_BLUES[_i - 1]\n", + "for _i in range(1, N_N_STRIPS + 1):\n", + " PLOT_COLORS_OPT[f\"n_{_i}\"] = N_GREENS[_i - 1]\n", + "PLOT_TITLE_OPT = \"Optical cross-section: segmented rib (Sze free-carrier Si)\"\n", "\n", "plot_plane_section(\n", " section_opt,\n", @@ -457,7 +555,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "### Optical material properties (1550 nm)\n", @@ -470,7 +568,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -492,7 +590,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "## Plot the 2D cross-section\n", @@ -505,7 +603,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -533,7 +631,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -554,7 +652,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "19", "metadata": {}, "source": [ "## RF simulation (50 GHz)\n", @@ -566,7 +664,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -608,7 +706,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -643,7 +741,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -658,7 +756,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -690,7 +788,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -702,7 +800,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -732,7 +830,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -750,7 +848,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "27", "metadata": {}, "source": [ "## Optical simulation (1550 nm)\n", @@ -770,7 +868,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": { "lines_to_next_cell": 2 }, @@ -795,13 +893,17 @@ "# Optical post-processing\n", "OPT_FIELD = \"E_real\"\n", "OPT_FIELD_TITLE = \"Optical Mode |E| at x=0 (1550 nm)\"\n", - "OPT_RIB_PHYSICAL_GROUPS = [\"slab\", \"p_rib\", \"n_rib\"]" + "OPT_RIB_PHYSICAL_GROUPS = (\n", + " [f\"p_{i}\" for i in range(1, N_P_STRIPS + 1)]\n", + " + [f\"n_{i}\" for i in range(1, N_N_STRIPS + 1)]\n", + " + [\"slab\"]\n", + ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -837,6 +939,17 @@ "sim_optical.set_material(\n", " \"si\", material_type=\"dielectric\", permittivity=si_opt.permittivity_scalar\n", ")\n", + "# Segmented free-carrier materials: Re(eps) -> permittivity,\n", + "# Im(eps) -> conductivity (sigma = omega*eps0*eps''). Depleted strips\n", + "# carry conductivity=None and stay pure dielectrics.\n", + "for _sname in seg_result_optical[\"segments\"]:\n", + " _mat = seg_result_optical[\"materials\"][_sname]\n", + " sim_optical.set_material(\n", + " _sname,\n", + " material_type=\"dielectric\",\n", + " permittivity=_mat.permittivity,\n", + " conductivity=_mat.conductivity,\n", + " )\n", "sim_optical.set_material(\n", " \"sio2\", material_type=\"dielectric\", permittivity=sio2_opt.permittivity_scalar\n", ")\n", @@ -856,7 +969,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -871,7 +984,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -882,7 +995,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -900,11 +1013,11 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ - "# --- Rib waveguide zoom: optical physical groups (all Si) ---\n", + "# --- Rib waveguide zoom: segmented optical physical groups ---\n", "pl = plot_fields_2d(\n", " OPT_OUTPUT_DIR,\n", " field=OPT_FIELD,\n", @@ -916,7 +1029,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "34", "metadata": {}, "source": [ "## Summary\n", @@ -928,9 +1041,11 @@ "|---|---|---|---|\n", "| Rib core | WG (1,0) | Si (intrinsic) | 2 |\n", "| Slab (90 nm) | SLAB90 (3,0) | Si (intrinsic) | 2 |\n", - "| PN junction (P) | P (21,0) | doped Si (p_rib) | 1.6x10^3 |\n", - "| Depletion strip | (22,0) | Si (eps 11.9, undoped) | — |\n", - "| PN junction (N) | N (20,0) | doped Si (n_rib) | 1.6x10^3 |\n", + "| PN strips (optical) | P (21,1..8) | free-carrier Si (p_1..p_8) | per-strip σ |\n", + "| PN strips (optical) | N (20,1..8) | free-carrier Si (n_1..n_8) | per-strip σ |\n", + "| PN junction (RF P) | P (21,0) | doped Si (p_rib) | 1.6x10^3 |\n", + "| Depletion strip (RF) | (22,0) | Si (eps 11.9, undoped) | — |\n", + "| PN junction (RF N) | N (20,0) | doped Si (n_rib) | 1.6x10^3 |\n", "| P+ graded inner/outer | PP (23,0)/(23,1) | doped Si | 2e4 / 8e4 |\n", "| N+ graded inner/outer | NPP (24,0)/(24,1) | doped Si | 2e4 / 8e4 |\n", "| Vias (S->P+, G->N+) | VIAC/VIA1/VIA2 | W/Al | 3.5x10^7 |\n", @@ -955,20 +1070,32 @@ " not metals, so they do not short-circuit the junction.\n", "- The depletion region is now represented either lumped or geometrically\n", " (auto-selected); earlier revisions omitted it entirely.\n", - "- The **plasma-dispersion effect** is not applied to the optical simulation;\n", - " the rib is treated as intrinsic Si at 1550 nm.\n", + "\n", + "**Free-carrier optical model** (1D Sze + Drude plasma dispersion):\n", + "- The 1D carrier profile follows the depletion approximation (quasi-neutral\n", + " bulk at `Na`/`Nd`, swept-out `[−xn, +xp]` slice at `ni`).\n", + "- Each carrier population contributes\n", + " `σ(ω) = N·q·μ/(1 + j·ω·τ)` with `τ = m*·μ/q`\n", + " (`m*_ce = 0.26·m0`, `m*_ch = 0.38·m0`); the complex permittivity is\n", + " `ε_eff = ε_bg + (σ_n + σ_p)/(j·ω)` evaluated at 1550 nm against the Si\n", + " Sellmeier background. At `1e18 cm⁻³` the quasi-neutral rib carries\n", + " `Δn ≈ −1e-3` with `σ ≈ 0.5 S/m`; the depletion slice stays at `ε_bg`.\n", + "- Palace takes `Re(ε) → Permittivity` and `Im(ε) → Conductivity`\n", + " (`σ = ω·ε0·ε″`); depleted strips carry no conductivity entry.\n", "\n", "**Optical-only component:**\n", - "- The optical run (`sim_optical`) uses a **simplified component** — the same\n", - " rib + slab + PN junction polygons, but no electrodes, vias, or graded doping.\n", - "- Every device region maps to **plain silicon** in `build_optical_cross_section()`,\n", - " so the optical mode sees one homogeneous Si body in uniform SiO2.\n" + "- The optical run (`sim_optical`) uses a **simplified component** — rib +\n", + " slab + **segmented** PN junction (8+8 uniform strips), but no electrodes,\n", + " vias, or graded doping.\n", + "- Every strip maps to its own free-carrier material via `device_materials` /\n", + " `extra_materials` in `build_optical_cross_section()` plus per-strip\n", + " `sim_optical.set_material()` overrides.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ diff --git a/src/gsim/common/cross_section.py b/src/gsim/common/cross_section.py index 61b094ef..68258ccb 100644 --- a/src/gsim/common/cross_section.py +++ b/src/gsim/common/cross_section.py @@ -233,7 +233,7 @@ def build_doped_cross_section( 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 + :func:`gsim.common.stack.pn_junction.make_doping_profile` plus any additional rib doping layers (e.g. a PN junction), and slices the component at the requested plane. @@ -361,6 +361,8 @@ def build_optical_cross_section( cladding_top: float = 2.0, device_material: str = "si", cladding_material: str = "sio2", + device_materials: Mapping[str, str] | None = None, + extra_materials: Mapping[str, Any] | None = None, mesh_resolution: str | float = "fine", verbose: bool = True, ) -> tuple[LayerStack, list[Rect2D] | list[RectYZ2D] | list[PolygonXY2D]]: @@ -368,25 +370,37 @@ def build_optical_cross_section( Builds a photonic ``LayerStack`` for a simplified device — e.g. a rib + slab + PN junction made of a single semiconductor — sitting in a uniform - cladding background. Unlike :func:`build_doped_cross_section`, every device - region is mapped to the same plain dielectric material (e.g. ``"si"``) and - no electrodes, vias, or graded doping are registered. A single ``oxide`` + cladding background. Unlike :func:`build_doped_cross_section`, no + electrodes, vias, or graded doping are registered. A single ``oxide`` dielectric slab (the cladding material) spans the full stack z-range, so the simulation domain is a uniform cladding with only the drawn device embedded in it. + By default every device region shares ``device_material`` (homogeneous + body). Pass ``device_materials`` to map individual regions to their own + materials (e.g. per-strip free-carrier permittivities from + :func:`gsim.common.stack.pn_junction.make_segmented_junction_profile`) + and ``extra_materials`` to register those ``MaterialProperties`` on the + stack. + Args: component: gdsfactory component the cross-section is extracted from. axis: Cross-section normal axis. value: Plane coordinate in um. device_layers: Mapping of ``name -> (gds_layer, zmin, zmax)`` for every patterned device region (e.g. ``{"core": ((1, 0), 0.0, 0.22)}``). - All regions share ``device_material``. substrate_thickness: Cladding thickness below z=0 in um. cladding_top: Cladding thickness above z=0 in um. - device_material: Material name for all device regions (default ``"si"``). + device_material: Default material name for all device regions. cladding_material: Material name of the uniform background (default ``"sio2"``). + device_materials: Optional per-region material override + (``{region_name: material_name}``); regions absent from the + mapping use ``device_material``. + extra_materials: Optional ``{material_name: MaterialProperties}`` + (or plain dicts) merged into ``stack.materials`` after the + database lookup, so custom per-region materials resolve + downstream. mesh_resolution: Mesh resolution assigned to the device ``Layer`` specs. verbose: Print the assembled stack and the extracted section. @@ -400,6 +414,7 @@ def build_optical_cross_section( stack = LayerStack(pdk_name="optical") + per_region = device_materials or {} for name, (gds_layer, zmin, zmax) in device_layers.items(): stack.layers[name] = Layer( name=name, @@ -407,7 +422,7 @@ def build_optical_cross_section( zmin=zmin, zmax=zmax, thickness=zmax - zmin, - material=device_material, + material=per_region.get(name, device_material), layer_type="dielectric", mesh_resolution=mesh_resolution, ) @@ -421,11 +436,14 @@ def build_optical_cross_section( } ) - for material in (device_material, cladding_material): + for material in {device_material, cladding_material} | set(per_region.values()): props = get_material_properties(material) if props is not None: stack.materials[material] = props.to_dict() + for name, mat in (extra_materials or {}).items(): + stack.materials[name] = mat.to_dict() if hasattr(mat, "to_dict") else mat + section = extract_plane_section( component.copy(), stack, diff --git a/src/gsim/common/stack/__init__.py b/src/gsim/common/stack/__init__.py index db2b7aa4..64c28687 100644 --- a/src/gsim/common/stack/__init__.py +++ b/src/gsim/common/stack/__init__.py @@ -23,7 +23,6 @@ import gdsfactory as gf import yaml -from gsim.common.stack.doping import make_doping_profile, make_pn_junction_profile from gsim.common.stack.extractor import ( Layer, LayerStack, @@ -31,14 +30,6 @@ extract_from_pdk, extract_layer_stack, ) -from gsim.common.stack.junction import ( - PNJunctionConfig, - built_in_voltage, - depletion_extents, - depletion_width, - junction_capacitance_per_area, - select_junction_mode, -) from gsim.common.stack.materials import ( MATERIALS_DB, DispersionModel, @@ -57,6 +48,30 @@ load_overlay, merge_overlay, ) +from gsim.common.stack.pn_junction import ( + M_CE_STAR, + M_CH_STAR, + MU_N_CM2_VS, + MU_P_CM2_VS, + NI_SI_300K_CM3, + SIGMA_NEGLIGIBLE_SM, + PNJunctionConfig, + built_in_voltage, + carrier_profile_1d, + default_eps_bg_rel, + depletion_extents, + depletion_width, + drude_relaxation_times, + epsilon_eff_relative, + junction_capacitance_per_area, + junction_epsilon_profile, + make_doping_profile, + make_pn_junction_profile, + make_segmented_junction_profile, + optical_params, + refractive_index, + select_junction_mode, +) from gsim.common.stack.visualization import ( StackLayer, parse_layer_stack, @@ -173,6 +188,12 @@ def load_stack_yaml(yaml_path: str | Path) -> LayerStack: __all__ = [ "MATERIALS_DB", + "MU_N_CM2_VS", + "MU_P_CM2_VS", + "M_CE_STAR", + "M_CH_STAR", + "NI_SI_300K_CM3", + "SIGMA_NEGLIGIBLE_SM", "DispersionModel", "Layer", "LayerStack", @@ -185,24 +206,32 @@ def load_stack_yaml(yaml_path: str | Path) -> LayerStack: "ValidationResult", "ValidityRange", "built_in_voltage", + "carrier_profile_1d", + "default_eps_bg_rel", "depletion_extents", "depletion_width", + "drude_relaxation_times", + "epsilon_eff_relative", "extract_from_pdk", "extract_layer_stack", "get_material_properties", "get_stack", "junction_capacitance_per_area", + "junction_epsilon_profile", "load_overlay", "load_stack_yaml", "make_doped_material", "make_doped_materials", "make_doping_profile", "make_pn_junction_profile", + "make_segmented_junction_profile", "merge_overlay", + "optical_params", "parse_layer_stack", "plot_stack", "print_stack", "print_stack_table", + "refractive_index", "resolve_material_at_wavelength", "select_junction_mode", "should_enable_dispersion", diff --git a/src/gsim/common/stack/doping.py b/src/gsim/common/stack/doping.py deleted file mode 100644 index 2d8d50ec..00000000 --- a/src/gsim/common/stack/doping.py +++ /dev/null @@ -1,384 +0,0 @@ -"""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 - -import logging -from typing import TYPE_CHECKING, Any, Literal, cast - -import gdsfactory as gf - -from gsim.common.stack.junction import ( - JUNCTION_MODE_FRACTION, - PNJunctionConfig, -) -from gsim.common.stack.materials import MaterialProperties, make_doped_materials - -if TYPE_CHECKING: - from gsim.common.stack.extractor import Layer - -logger = logging.getLogger(__name__) - -_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 - - -def _as_junction_config( - junction: PNJunctionConfig | dict[str, Any], -) -> PNJunctionConfig: - """Accept a config object or plain dict for the junction parameters.""" - if isinstance(junction, PNJunctionConfig): - return junction - return PNJunctionConfig.model_validate(junction) - - -def _add_rect( - comp: gf.Component, - *, - length: float, - y0: float, - y1: float, - gds_layer: tuple[int, int], -) -> float: - """Draw a rectangle spanning ``[y0, y1]`` and return its y-centre.""" - rect = comp << gf.c.rectangle((length, y1 - y0), layer=gds_layer) - rect.y = (y0 + y1) / 2 - return (y0 + y1) / 2 - - -def make_pn_junction_profile( - comp: gf.Component, - *, - length: float, - center_y: float, - rib_width: float, - junction: PNJunctionConfig | dict[str, Any], - p_region: tuple[str, tuple[int, int], float], - n_region: tuple[str, tuple[int, int], float], - junction_region: tuple[str, tuple[int, int]] | None = None, - zmin: float = 0.0, - zmax: float | None = None, - fmax: float = 200e9, - mode: Literal["auto", "capacitance", "high_res"] = "auto", - mode_fraction: float = JUNCTION_MODE_FRACTION, - mesh_resolution: str | float = "fine", -) -> dict[str, dict[str, Any]]: - """Build P / depletion-junction / N rib regions around ``center_y``. - - The depletion width ``W`` (and its asymmetric split ``xp``/``xn`` into - the P and N halves) comes from :class:`PNJunctionConfig`, which - implements the textbook abrupt/linearly-graded junction formulas - (Sze, *Physics of Semiconductor Devices*, ch. 2). - - Two representation modes are supported: - - - ``"high_res"``: three contiguous rectangles are drawn — N - ``[cy - rib_width/2, cy - xn]``, depleted-junction dielectric strip - ``[cy - xn, cy + xp]``, P ``[cy + xp, cy + rib_width/2]``. The - junction strip is registered as a patterned dielectric with a real - GDS layer so it appears on the simulation mesh. - - ``"capacitance"``: geometry is unchanged from a plain P/N split - (adjacent half-rectangles); no junction polygon is drawn and callers - apply the computed capacitance as a lumped impedance boundary instead - (see ``PalaceSimMixin.set_pn_junction``). - - With ``mode="auto"`` the choice falls out of - :func:`gsim.common.stack.junction.select_junction_mode`: the strip is - meshed only when ``W >= mode_fraction * min(P flank, N flank)``, where - each flank is ``rib_width / 2``. - - Args: - comp: gdsfactory component the rectangles are added to. - length: Rectangle length along the propagation direction (um). - center_y: Y coordinate of the metallurgical junction / rib centre. - rib_width: Full rib width (um); P occupies the upper half, N the - lower half. - junction: Depletion-model parameters - (:class:`PNJunctionConfig` or its dict form). - p_region: ``(name, gds_layer, sigma_S_per_m)`` for the P region. - n_region: ``(name, gds_layer, sigma_S_per_m)`` for the N region. - junction_region: ``(name, gds_layer)`` used to register the - depletion strip in high-res mode. Required when the selected - mode is ``"high_res"``; ignored in capacitance mode. - zmin: Bottom z of the regions (um). - zmax: Top z of the regions (um); defaults to ``zmin + 0.22``. - fmax: Upper frequency of the Drude-model validity range (Hz). - mode: ``"auto"``, ``"capacitance"`` or ``"high_res"``. - mode_fraction: Auto-mode threshold fraction (~1/5 default). - mesh_resolution: Mesh resolution assigned to the generated layers. - - Returns: - Dict with keys: - - - ``layer_specs``: ``{name: Layer}`` for every drawn region. - - ``materials``: ``{name: MaterialProperties}`` (Drude models for - P/N, plain dielectric for the junction strip). - - ``centres``: ``{role: y_centre}`` for drawn regions. - - ``junction``: computed quantities (widths, capacitance, chosen - mode and selection reason). - """ - from gsim.common.stack.extractor import Layer - from gsim.common.stack.junction import select_junction_mode - - cfg = _as_junction_config(junction) - p_name, p_layer, p_sigma = p_region - n_name, n_layer, n_sigma = n_region - - ztop = 0.22 if zmax is None else zmax - if ztop <= zmin: - raise ValueError("zmax must exceed zmin.") - if length <= 0: - raise ValueError("length must be positive.") - if cfg.xp_um + cfg.xn_um > rib_width: - raise ValueError( - f"Depletion width W={cfg.w_um:.4g} um does not fit in the " - f"{rib_width:.4g} um rib." - ) - - flank_um = rib_width / 2 - if mode == "auto": - mode = select_junction_mode( - cfg.w_um, flank_um, flank_um, fraction=mode_fraction - ) - reason = ( - f"W={cfg.w_um:.4g} um vs threshold " - f"{mode_fraction * flank_um:.4g} um (= {mode_fraction} * flank)" - ) - else: - reason = f"forced by caller (mode={mode!r})" - logger.info("PN junction mode: %s (%s)", mode, reason) - - 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, float] = result["centres"] - - def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: - return Layer( - name=name, - gds_layer=gds_layer, - zmin=zmin, - zmax=ztop, - thickness=ztop - zmin, - material=name, - layer_type="dielectric", - mesh_resolution=mesh_resolution, - ) - - xp, xn = cfg.xp_um, cfg.xn_um - - # N region: lower half, trimmed by xn when the strip is meshed. - n_y0 = center_y - flank_um - n_y1 = center_y if mode == "capacitance" else center_y - xn - centres["n"] = _add_rect( - comp, length=length, y0=n_y0, y1=n_y1, gds_layer=tuple(n_layer) - ) - layer_specs[n_name] = _doped_spec(n_name, tuple(n_layer), n_sigma) - - # P region: upper half, trimmed by xp when the strip is meshed. - p_y0 = center_y if mode == "capacitance" else center_y + xp - p_y1 = center_y + flank_um - centres["p"] = _add_rect( - comp, length=length, y0=p_y0, y1=p_y1, gds_layer=tuple(p_layer) - ) - layer_specs[p_name] = _doped_spec(p_name, tuple(p_layer), p_sigma) - - materials.update( - make_doped_materials( - [(p_name, p_sigma), (n_name, n_sigma)], - permittivity=cfg.permittivity, - fmax=fmax, - source_prefix="doped Si", - ) - ) - - if mode == "high_res": - if junction_region is None: - raise ValueError( - "mode='high_res' requires junction_region=(name, gds_layer)." - ) - j_name, j_layer = junction_region - centres["junction"] = _add_rect( - comp, - length=length, - y0=center_y - xn, - y1=center_y + xp, - gds_layer=tuple(j_layer), - ) - layer_specs[j_name] = Layer( - name=j_name, - gds_layer=tuple(j_layer), - zmin=zmin, - zmax=ztop, - thickness=ztop - zmin, - material=j_name, - layer_type="dielectric", - mesh_resolution=mesh_resolution, - ) - # Depleted silicon has no free carriers: pure real permittivity. - materials[j_name] = MaterialProperties( - permittivity=cfg.permittivity, - dispersion_models=[], - ) - - result["junction"] = { - **cfg.to_metadata(), - "c_f": cfg.capacitance(length, ztop - zmin), - "mode": mode, - "selection_reason": reason, - } - return result - - -__all__ = ["make_doping_profile", "make_pn_junction_profile"] diff --git a/src/gsim/common/stack/junction.py b/src/gsim/common/stack/junction.py deleted file mode 100644 index 3e796aca..00000000 --- a/src/gsim/common/stack/junction.py +++ /dev/null @@ -1,439 +0,0 @@ -"""PN-junction depletion model (Sze, *Physics of Semiconductor Devices*). - -This module implements the textbook depletion approximation for an abrupt or -linearly graded PN junction: - -- S. M. Sze and K. K. Ng, *Physics of Semiconductor Devices*, 3rd ed., - Wiley (2007), chapter 2 ("p-n Junction Diodes"). - -Provided quantities (all concentrations in ``cm^-3``, lengths in ``um``): - -1. Built-in potential:: - - V_bi = (k_B T / q) ln(Na Nd / ni^2) (Sze eq. 2.60) - -2. Depletion width under reverse bias VR (abrupt junction):: - - W = sqrt( 2 eps_s (V_bi + VR) / q * (Na + Nd)/(Na Nd) ) (eq. 2.66) - x_p = W Nd / (Na + Nd) (spilled into the P side) - x_n = W Na / (Na + Nd) (spilled into the N side) - -3. Depletion width for a linearly graded junction with grade constant - ``a = |dN/dx|`` near the metallurgical junction:: - - W = [ 12 eps_s (V_bi + VR) / (q a) ]^(1/3) (eq. 2.72) - -4. Junction capacitance per unit area (parallel-plate form of the depletion - charge, valid for W much smaller than the device lateral dimensions):: - - C_j = eps_s / W - -The same module also provides :func:`select_junction_mode`, which decides -whether the depletion strip can be resolved on the simulation mesh -(``"high_res"``) or should be collapsed into a lumped capacitance boundary -(``"capacitance"``). - -Example: -------- - >>> from gsim.common.stack.junction import PNJunctionConfig - >>> junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=0.0) - >>> junc.v_bi # built-in potential [V] - >>> junc.w_um # total depletion width [um] - >>> junc.xp_um # depletion extent into the P side [um] - >>> junc.xn_um # depletion extent into the N side [um] - >>> junc.capacitance(length_um=10.0, height_um=0.22) # absolute C [F] -""" - -from __future__ import annotations - -import math -from typing import Any, Literal, Self - -from pydantic import BaseModel, ConfigDict, Field, model_validator -from scipy.constants import Boltzmann as KB # noqa: N814 -from scipy.constants import elementary_charge as Q # noqa: N812 -from scipy.constants import epsilon_0 as EPS0 # noqa: N812 - -__all__ = [ - "DEFAULT_SI_PERMITTIVITY", - "JUNCTION_MODE_FRACTION", - "NI_SI_300K_CM3", - "PNJunctionConfig", - "built_in_voltage", - "depletion_extents", - "depletion_width", - "junction_capacitance_per_area", - "select_junction_mode", -] - -#: Intrinsic carrier concentration of silicon at 300 K in cm^-3. -#: Classic textbook value used by Sze; override for other materials/T. -NI_SI_300K_CM3: float = 1.5e10 - -#: Default relative permittivity of depleted (intrinsic) silicon. -DEFAULT_SI_PERMITTIVITY: float = 11.9 - -#: A depletion width is considered mesh-resolvable when it reaches this -#: fraction of the smallest doped section flanking the junction. -JUNCTION_MODE_FRACTION: float = 0.2 - -JunctionMode = Literal["capacitance", "high_res"] - - -def built_in_voltage( - na_cm3: float, - nd_cm3: float, - *, - temperature_k: float = 300.0, - ni_cm3: float = NI_SI_300K_CM3, -) -> float: - """Compute the built-in potential ``V_bi`` of a PN junction in volts. - - Implements ``V_bi = (k_B T / q) ln(Na Nd / ni^2)`` (Sze ch. 2). - - Args: - na_cm3: Acceptor concentration on the P side in cm^-3 (> 0). - nd_cm3: Donor concentration on the N side in cm^-3 (> 0). - temperature_k: Lattice temperature in kelvin (> 0). - ni_cm3: Intrinsic carrier concentration in cm^-3 (> 0). - - Returns: - Built-in potential in volts. - - Raises: - ValueError: If any input is non-positive or ``Na*Nd <= ni**2``. - """ - if na_cm3 <= 0 or nd_cm3 <= 0: - raise ValueError("Doping concentrations must be positive (cm^-3).") - if temperature_k <= 0: - raise ValueError("temperature_k must be positive.") - if ni_cm3 <= 0: - raise ValueError("ni_cm3 must be positive.") - product = na_cm3 * nd_cm3 - if product <= ni_cm3**2: - raise ValueError( - f"Na*Nd ({product:.3g} cm^-6) must exceed ni^2 " - f"({ni_cm3**2:.3g} cm^-6); degenerate case has no junction." - ) - vt = KB * temperature_k / Q - return float(vt * math.log(product / ni_cm3**2)) - - -def _validate_bias(v_reverse: float, v_bi: float) -> None: - """Reject bias points beyond flat-band (no physical solution).""" - if v_bi + v_reverse <= 0: - raise ValueError( - f"V_bi + v_reverse = {v_bi + v_reverse:.4g} V must be > 0 " - "(applied forward bias beyond flat-band has no solution)." - ) - - -def _eps_si(permittivity: float) -> float: - """Return absolute permittivity in F/m from a relative value.""" - if permittivity < 1.0: - raise ValueError("permittivity must be >= 1.") - return permittivity * EPS0 - - -def depletion_width( - na_cm3: float, - nd_cm3: float, - *, - v_reverse: float = 0.0, - temperature_k: float = 300.0, - ni_cm3: float = NI_SI_300K_CM3, - permittivity: float = DEFAULT_SI_PERMITTIVITY, - grading: Literal["abrupt", "linear"] = "abrupt", - grade_const_cm4: float | None = None, -) -> float: - """Compute the total depletion width ``W`` in micrometers. - - Args: - na_cm3: Acceptor concentration in cm^-3 (> 0). - nd_cm3: Donor concentration in cm^-3 (> 0). - v_reverse: Applied reverse-bias voltage in volts (positive = reverse). - Negative values model forward bias down to (but excluding) - flat-band. - temperature_k: Lattice temperature in kelvin. - ni_cm3: Intrinsic carrier concentration in cm^-3. - permittivity: Relative permittivity of the semiconductor. - grading: ``"abrupt"`` (step junction) or ``"linear"`` (linearly - graded). - grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4 for - ``grading="linear"``. - - Returns: - Total depletion width in micrometers. - - Raises: - ValueError: On non-positive inputs, missing grade constant, or bias - beyond flat-band. - """ - v_bi = built_in_voltage(na_cm3, nd_cm3, temperature_k=temperature_k, ni_cm3=ni_cm3) - _validate_bias(v_reverse, v_bi) - eps_s = _eps_si(permittivity) - - if grading == "linear": - if grade_const_cm4 is None or grade_const_cm4 <= 0: - raise ValueError("grading='linear' requires grade_const_cm4 > 0.") - # a in m^-4 (1 cm^-4 = 1e6 m^-4); W comes out in meters. - a_m4 = grade_const_cm4 * 1e6 - w_m = (12.0 * eps_s * (v_bi + v_reverse) / (Q * a_m4)) ** (1.0 / 3.0) - return float(w_m * 1e6) - - if grading != "abrupt": - raise ValueError(f"Unknown grading type: {grading!r}") - - # Abrupt junction: W = sqrt(2 eps_s (V_bi+VR)/q * (Na+Nd)/(NaNd)). - na_m3 = na_cm3 * 1e6 - nd_m3 = nd_cm3 * 1e6 - w_m = math.sqrt( - 2.0 * eps_s * (v_bi + v_reverse) / Q * (na_m3 + nd_m3) / (na_m3 * nd_m3) - ) - return float(w_m * 1e6) - - -def depletion_extents( - na_cm3: float, - nd_cm3: float, - *, - w_um: float, - grading: Literal["abrupt", "linear"] = "abrupt", -) -> tuple[float, float]: - """Split a total depletion width into P-side/N-side extents in micrometers. - - For an abrupt junction the depletion spills asymmetrically:: - - x_p = W Nd / (Na + Nd), x_n = W Na / (Na + Nd) - - A linearly graded junction is symmetric around the metallurgical - junction, so ``x_p = x_n = W/2``. - - Args: - na_cm3: Acceptor concentration in cm^-3 (> 0). - nd_cm3: Donor concentration in cm^-3 (> 0). - w_um: Total depletion width in micrometers (from - :func:`depletion_width`). - grading: Junction grading type. - - Returns: - ``(xp_um, xn_um)`` — extents spilled into the P and N sides. - """ - if na_cm3 <= 0 or nd_cm3 <= 0: - raise ValueError("Doping concentrations must be positive (cm^-3).") - if w_um < 0: - raise ValueError("w_um must be non-negative.") - if grading == "linear": - return w_um / 2.0, w_um / 2.0 - total = na_cm3 + nd_cm3 - return w_um * nd_cm3 / total, w_um * na_cm3 / total - - -def junction_capacitance_per_area( - permittivity: float, - w_um: float, -) -> float: - """Depletion capacitance per unit area ``C_j = eps_s / W`` in F/m^2. - - Args: - permittivity: Relative permittivity of the semiconductor. - w_um: Total depletion width in micrometers (> 0). - - Returns: - Capacitance per unit area in F/m^2. - """ - if w_um <= 0: - raise ValueError("w_um must be positive.") - return _eps_si(permittivity) / (w_um * 1e-6) - - -def select_junction_mode( - w_um: float, - p_extent_um: float, - n_extent_um: float, - *, - fraction: float = JUNCTION_MODE_FRACTION, -) -> JunctionMode: - """Choose how to represent the depletion region in a simulation. - - The depletion strip is meshed explicitly (``"high_res"``) when its width - is comparable to the doped sections flanking it — specifically when - ``w_um >= fraction * min(p_extent, n_extent)``. Otherwise the region is - far thinner than its neighbours and meshing it would only bloat the - model, so a lumped capacitance boundary is used instead - (``"capacitance"``). - - Args: - w_um: Total depletion width in micrometers (> 0). - p_extent_um: Size of the doped section flanking the junction on the - P side (micrometers, > 0). - n_extent_um: Size of the doped section flanking the junction on the - N side (micrometers, > 0). - fraction: Resolvability threshold as a fraction of the smaller flank - (default ~1/5). - - Returns: - ``"high_res"`` when the geometry should carry the depletion strip, - ``"capacitance"`` otherwise. - """ - if w_um <= 0: - raise ValueError("w_um must be positive.") - if p_extent_um <= 0 or n_extent_um <= 0: - raise ValueError("Flank extents must be positive.") - if not 0 < fraction <= 1: - raise ValueError("fraction must lie in (0, 1].") - threshold_um = fraction * min(p_extent_um, n_extent_um) - return "high_res" if w_um >= threshold_um else "capacitance" - - -class PNJunctionConfig(BaseModel): - """Parameters of a PN-junction depletion model (depletion approximation). - - Concentrations use the semiconductor-industry convention (cm^-3); - derived lengths are exposed in micrometers and capacitances in farads. - See module docstring for the underlying formulas (Sze ch. 2). - - Attributes: - na_cm3: Acceptor concentration on the P side (cm^-3). - nd_cm3: Donor concentration on the N side (cm^-3). - v_reverse: Applied reverse bias in volts (positive = reverse; - negative values model forward bias below flat-band). - temperature_k: Lattice temperature in kelvin. - ni_cm3: Intrinsic carrier concentration (cm^-3). - permittivity: Relative permittivity of the depleted semiconductor. - grading: ``"abrupt"`` or ``"linear"`` junction profile. - grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4, required - when ``grading="linear"``. - """ - - model_config = ConfigDict(validate_assignment=True) - - na_cm3: float = Field(gt=0, description="Acceptor concentration (cm^-3)") - nd_cm3: float = Field(gt=0, description="Donor concentration (cm^-3)") - v_reverse: float = Field( - default=0.0, description="Applied reverse bias [V] (positive = reverse)" - ) - temperature_k: float = Field(default=300.0, gt=0, description="Temperature [K]") - ni_cm3: float = Field( - default=NI_SI_300K_CM3, gt=0, description="Intrinsic carriers (cm^-3)" - ) - permittivity: float = Field( - default=DEFAULT_SI_PERMITTIVITY, - ge=1.0, - description="Relative permittivity of the semiconductor", - ) - grading: Literal["abrupt", "linear"] = Field(default="abrupt") - grade_const_cm4: float | None = Field( - default=None, gt=0, description="Grade constant a = |dN/dx| (cm^-4)" - ) - - @model_validator(mode="after") - def _validate_physics(self) -> Self: - """Check grading configuration and bias range.""" - if self.grading == "linear" and self.grade_const_cm4 is None: - raise ValueError("grading='linear' requires grade_const_cm4.") - _validate_bias(self.v_reverse, self.v_bi) - return self - - @property - def v_bi(self) -> float: - """Built-in potential in volts.""" - return built_in_voltage( - self.na_cm3, - self.nd_cm3, - temperature_k=self.temperature_k, - ni_cm3=self.ni_cm3, - ) - - @property - def w_um(self) -> float: - """Total depletion width in micrometers at the configured bias.""" - return depletion_width( - self.na_cm3, - self.nd_cm3, - v_reverse=self.v_reverse, - temperature_k=self.temperature_k, - ni_cm3=self.ni_cm3, - permittivity=self.permittivity, - grading=self.grading, - grade_const_cm4=self.grade_const_cm4, - ) - - @property - def xp_um(self) -> float: - """Depletion extent spilled into the P side (micrometers).""" - xp, _xn = depletion_extents( - self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading - ) - return xp - - @property - def xn_um(self) -> float: - """Depletion extent spilled into the N side (micrometers).""" - _xp, xn = depletion_extents( - self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading - ) - return xn - - @property - def c_per_area(self) -> float: - """Junction capacitance per unit area in F/m^2 (``eps_s / W``).""" - return junction_capacitance_per_area(self.permittivity, self.w_um) - - def capacitance(self, length_um: float, height_um: float) -> float: - """Absolute junction capacitance for a rectangular junction face. - - Treats the depletion strip as a parallel-plate capacitor of area - ``length x height`` filled with the depleted semiconductor: - ``C = eps_s * A / W``. - - Args: - length_um: Device length along the propagation direction (um). - height_um: Junction z-extent (um), e.g. the rib height. - - Returns: - Absolute capacitance in farads. - """ - if length_um <= 0 or height_um <= 0: - raise ValueError("length_um and height_um must be positive.") - area_m2 = length_um * height_um * 1e-12 - return float(self.c_per_area * area_m2) - - def select_mode( - self, - p_extent_um: float, - n_extent_um: float, - *, - fraction: float = JUNCTION_MODE_FRACTION, - ) -> JunctionMode: - """Auto-select the representation mode for this junction. - - Thin wrapper around :func:`select_junction_mode` using this config's - computed depletion width. - - Args: - p_extent_um: Size of the doped flank on the P side (um). - n_extent_um: Size of the doped flank on the N side (um). - fraction: Resolvability threshold fraction (~1/5 default). - - Returns: - ``"high_res"`` or ``"capacitance"``. - """ - return select_junction_mode( - self.w_um, p_extent_um, n_extent_um, fraction=fraction - ) - - def to_metadata(self) -> dict[str, Any]: - """Return a plain-dict summary of the computed junction quantities.""" - return { - "na_cm3": self.na_cm3, - "nd_cm3": self.nd_cm3, - "v_reverse": self.v_reverse, - "temperature_k": self.temperature_k, - "v_bi": self.v_bi, - "w_um": self.w_um, - "xp_um": self.xp_um, - "xn_um": self.xn_um, - "c_per_area_f_m2": self.c_per_area, - "grading": self.grading, - } diff --git a/src/gsim/common/stack/pn_junction.py b/src/gsim/common/stack/pn_junction.py new file mode 100644 index 00000000..8e2ea876 --- /dev/null +++ b/src/gsim/common/stack/pn_junction.py @@ -0,0 +1,1289 @@ +"""PN-junction model (Sze, *Physics of Semiconductor Devices*). + +This module consolidates the PN-junction helpers: the textbook depletion +approximation for abrupt or linearly graded junctions, the 1D free-carrier +plasma-dispersion model for the complex optical permittivity, and the +solver-agnostic geometry builders for contiguous doping regions. + +- S. M. Sze and K. K. Ng, *Physics of Semiconductor Devices*, 3rd ed., + Wiley (2007), chapter 2 ("p-n Junction Diodes"). + +Depletion quantities (all concentrations in ``cm^-3``, lengths in ``um``): + +1. Built-in potential:: + + V_bi = (k_B T / q) ln(Na Nd / ni^2) (Sze eq. 2.60) + +2. Depletion width under reverse bias VR (abrupt junction):: + + W = sqrt( 2 eps_s (V_bi + VR) / q * (Na + Nd)/(Na Nd) ) (eq. 2.66) + x_p = W Nd / (Na + Nd) (spilled into the P side) + x_n = W Na / (Na + Nd) (spilled into the N side) + +3. Depletion width for a linearly graded junction with grade constant + ``a = |dN/dx|`` near the metallurgical junction:: + + W = [ 12 eps_s (V_bi + VR) / (q a) ]^(1/3) (eq. 2.72) + +4. Junction capacitance per unit area (parallel-plate form of the depletion + charge, valid for W much smaller than the device lateral dimensions):: + + C_j = eps_s / W + +The same module also provides :func:`select_junction_mode`, which decides +whether the depletion strip can be resolved on the simulation mesh +(``"high_res"``) or should be collapsed into a lumped capacitance boundary +(``"capacitance"``), and :func:`junction_epsilon_profile`, which evaluates +the 1D complex permittivity across the junction at optical frequencies +from the Drude plasma-dispersion of the free carriers. + +Example: +------- + >>> from gsim.common.stack.pn_junction import PNJunctionConfig + >>> junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=0.0) + >>> junc.v_bi # built-in potential [V] + >>> junc.w_um # total depletion width [um] + >>> junc.xp_um # depletion extent into the P side [um] + >>> junc.xn_um # depletion extent into the N side [um] + >>> junc.capacitance(length_um=10.0, height_um=0.22) # absolute C [F] +""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING, Any, Literal, Self, cast + +import gdsfactory as gf +import numpy as np +from pydantic import BaseModel, ConfigDict, Field, model_validator +from scipy.constants import Boltzmann as KB # noqa: N814 +from scipy.constants import c as C_LIGHT # noqa: N812 +from scipy.constants import electron_mass as M0 # noqa: N812 +from scipy.constants import elementary_charge as Q # noqa: N812 +from scipy.constants import epsilon_0 as EPS0 # noqa: N812 + +from gsim.common.stack.materials import MaterialProperties, make_doped_materials + +if TYPE_CHECKING: + from gsim.common.stack.extractor import Layer + +logger = logging.getLogger(__name__) + +__all__ = [ + "DEFAULT_SI_PERMITTIVITY", + "JUNCTION_MODE_FRACTION", + "MU_N_CM2_VS", + "MU_P_CM2_VS", + "M_CE_STAR", + "M_CH_STAR", + "NI_SI_300K_CM3", + "SIGMA_NEGLIGIBLE_SM", + "PNJunctionConfig", + "built_in_voltage", + "carrier_profile_1d", + "default_eps_bg_rel", + "depletion_extents", + "depletion_width", + "drude_relaxation_times", + "epsilon_eff_relative", + "junction_capacitance_per_area", + "junction_epsilon_profile", + "make_doping_profile", + "make_pn_junction_profile", + "make_segmented_junction_profile", + "optical_params", + "refractive_index", + "select_junction_mode", +] + +#: Intrinsic carrier concentration of silicon at 300 K in cm^-3. +#: Classic textbook value used by Sze; override for other materials/T. +NI_SI_300K_CM3: float = 1.5e10 + +#: Default relative permittivity of depleted (intrinsic) silicon. +DEFAULT_SI_PERMITTIVITY: float = 11.9 + +#: A depletion width is considered mesh-resolvable when it reaches this +#: fraction of the smallest doped section flanking the junction. +JUNCTION_MODE_FRACTION: float = 0.2 + +JunctionMode = Literal["capacitance", "high_res"] + + +def built_in_voltage( + na_cm3: float, + nd_cm3: float, + *, + temperature_k: float = 300.0, + ni_cm3: float = NI_SI_300K_CM3, +) -> float: + """Compute the built-in potential ``V_bi`` of a PN junction in volts. + + Implements ``V_bi = (k_B T / q) ln(Na Nd / ni^2)`` (Sze ch. 2). + + Args: + na_cm3: Acceptor concentration on the P side in cm^-3 (> 0). + nd_cm3: Donor concentration on the N side in cm^-3 (> 0). + temperature_k: Lattice temperature in kelvin (> 0). + ni_cm3: Intrinsic carrier concentration in cm^-3 (> 0). + + Returns: + Built-in potential in volts. + + Raises: + ValueError: If any input is non-positive or ``Na*Nd <= ni**2``. + """ + if na_cm3 <= 0 or nd_cm3 <= 0: + raise ValueError("Doping concentrations must be positive (cm^-3).") + if temperature_k <= 0: + raise ValueError("temperature_k must be positive.") + if ni_cm3 <= 0: + raise ValueError("ni_cm3 must be positive.") + product = na_cm3 * nd_cm3 + if product <= ni_cm3**2: + raise ValueError( + f"Na*Nd ({product:.3g} cm^-6) must exceed ni^2 " + f"({ni_cm3**2:.3g} cm^-6); degenerate case has no junction." + ) + vt = KB * temperature_k / Q + return float(vt * math.log(product / ni_cm3**2)) + + +def _validate_bias(v_reverse: float, v_bi: float) -> None: + """Reject bias points beyond flat-band (no physical solution).""" + if v_bi + v_reverse <= 0: + raise ValueError( + f"V_bi + v_reverse = {v_bi + v_reverse:.4g} V must be > 0 " + "(applied forward bias beyond flat-band has no solution)." + ) + + +def _eps_si(permittivity: float) -> float: + """Return absolute permittivity in F/m from a relative value.""" + if permittivity < 1.0: + raise ValueError("permittivity must be >= 1.") + return permittivity * EPS0 + + +def depletion_width( + na_cm3: float, + nd_cm3: float, + *, + v_reverse: float = 0.0, + temperature_k: float = 300.0, + ni_cm3: float = NI_SI_300K_CM3, + permittivity: float = DEFAULT_SI_PERMITTIVITY, + grading: Literal["abrupt", "linear"] = "abrupt", + grade_const_cm4: float | None = None, +) -> float: + """Compute the total depletion width ``W`` in micrometers. + + Args: + na_cm3: Acceptor concentration in cm^-3 (> 0). + nd_cm3: Donor concentration in cm^-3 (> 0). + v_reverse: Applied reverse-bias voltage in volts (positive = reverse). + Negative values model forward bias down to (but excluding) + flat-band. + temperature_k: Lattice temperature in kelvin. + ni_cm3: Intrinsic carrier concentration in cm^-3. + permittivity: Relative permittivity of the semiconductor. + grading: ``"abrupt"`` (step junction) or ``"linear"`` (linearly + graded). + grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4 for + ``grading="linear"``. + + Returns: + Total depletion width in micrometers. + + Raises: + ValueError: On non-positive inputs, missing grade constant, or bias + beyond flat-band. + """ + v_bi = built_in_voltage(na_cm3, nd_cm3, temperature_k=temperature_k, ni_cm3=ni_cm3) + _validate_bias(v_reverse, v_bi) + eps_s = _eps_si(permittivity) + + if grading == "linear": + if grade_const_cm4 is None or grade_const_cm4 <= 0: + raise ValueError("grading='linear' requires grade_const_cm4 > 0.") + # a in m^-4 (1 cm^-4 = 1e6 m^-4); W comes out in meters. + a_m4 = grade_const_cm4 * 1e6 + w_m = (12.0 * eps_s * (v_bi + v_reverse) / (Q * a_m4)) ** (1.0 / 3.0) + return float(w_m * 1e6) + + if grading != "abrupt": + raise ValueError(f"Unknown grading type: {grading!r}") + + # Abrupt junction: W = sqrt(2 eps_s (V_bi+VR)/q * (Na+Nd)/(NaNd)). + na_m3 = na_cm3 * 1e6 + nd_m3 = nd_cm3 * 1e6 + w_m = math.sqrt( + 2.0 * eps_s * (v_bi + v_reverse) / Q * (na_m3 + nd_m3) / (na_m3 * nd_m3) + ) + return float(w_m * 1e6) + + +def depletion_extents( + na_cm3: float, + nd_cm3: float, + *, + w_um: float, + grading: Literal["abrupt", "linear"] = "abrupt", +) -> tuple[float, float]: + """Split a total depletion width into P-side/N-side extents in micrometers. + + For an abrupt junction the depletion spills asymmetrically:: + + x_p = W Nd / (Na + Nd), x_n = W Na / (Na + Nd) + + A linearly graded junction is symmetric around the metallurgical + junction, so ``x_p = x_n = W/2``. + + Args: + na_cm3: Acceptor concentration in cm^-3 (> 0). + nd_cm3: Donor concentration in cm^-3 (> 0). + w_um: Total depletion width in micrometers (from + :func:`depletion_width`). + grading: Junction grading type. + + Returns: + ``(xp_um, xn_um)`` — extents spilled into the P and N sides. + """ + if na_cm3 <= 0 or nd_cm3 <= 0: + raise ValueError("Doping concentrations must be positive (cm^-3).") + if w_um < 0: + raise ValueError("w_um must be non-negative.") + if grading == "linear": + return w_um / 2.0, w_um / 2.0 + total = na_cm3 + nd_cm3 + return w_um * nd_cm3 / total, w_um * na_cm3 / total + + +def junction_capacitance_per_area( + permittivity: float, + w_um: float, +) -> float: + """Depletion capacitance per unit area ``C_j = eps_s / W`` in F/m^2. + + Args: + permittivity: Relative permittivity of the semiconductor. + w_um: Total depletion width in micrometers (> 0). + + Returns: + Capacitance per unit area in F/m^2. + """ + if w_um <= 0: + raise ValueError("w_um must be positive.") + return _eps_si(permittivity) / (w_um * 1e-6) + + +def select_junction_mode( + w_um: float, + p_extent_um: float, + n_extent_um: float, + *, + fraction: float = JUNCTION_MODE_FRACTION, +) -> JunctionMode: + """Choose how to represent the depletion region in a simulation. + + The depletion strip is meshed explicitly (``"high_res"``) when its width + is comparable to the doped sections flanking it — specifically when + ``w_um >= fraction * min(p_extent, n_extent)``. Otherwise the region is + far thinner than its neighbours and meshing it would only bloat the + model, so a lumped capacitance boundary is used instead + (``"capacitance"``). + + Args: + w_um: Total depletion width in micrometers (> 0). + p_extent_um: Size of the doped section flanking the junction on the + P side (micrometers, > 0). + n_extent_um: Size of the doped section flanking the junction on the + N side (micrometers, > 0). + fraction: Resolvability threshold as a fraction of the smaller flank + (default ~1/5). + + Returns: + ``"high_res"`` when the geometry should carry the depletion strip, + ``"capacitance"`` otherwise. + """ + if w_um <= 0: + raise ValueError("w_um must be positive.") + if p_extent_um <= 0 or n_extent_um <= 0: + raise ValueError("Flank extents must be positive.") + if not 0 < fraction <= 1: + raise ValueError("fraction must lie in (0, 1].") + threshold_um = fraction * min(p_extent_um, n_extent_um) + return "high_res" if w_um >= threshold_um else "capacitance" + + +class PNJunctionConfig(BaseModel): + """Parameters of a PN-junction depletion model (depletion approximation). + + Concentrations use the semiconductor-industry convention (cm^-3); + derived lengths are exposed in micrometers and capacitances in farads. + See module docstring for the underlying formulas (Sze ch. 2). + + Attributes: + na_cm3: Acceptor concentration on the P side (cm^-3). + nd_cm3: Donor concentration on the N side (cm^-3). + v_reverse: Applied reverse bias in volts (positive = reverse; + negative values model forward bias below flat-band). + temperature_k: Lattice temperature in kelvin. + ni_cm3: Intrinsic carrier concentration (cm^-3). + permittivity: Relative permittivity of the depleted semiconductor. + grading: ``"abrupt"`` or ``"linear"`` junction profile. + grade_const_cm4: Grade constant ``a = |dN/dx|`` in cm^-4, required + when ``grading="linear"``. + """ + + model_config = ConfigDict(validate_assignment=True) + + na_cm3: float = Field(gt=0, description="Acceptor concentration (cm^-3)") + nd_cm3: float = Field(gt=0, description="Donor concentration (cm^-3)") + v_reverse: float = Field( + default=0.0, description="Applied reverse bias [V] (positive = reverse)" + ) + temperature_k: float = Field(default=300.0, gt=0, description="Temperature [K]") + ni_cm3: float = Field( + default=NI_SI_300K_CM3, gt=0, description="Intrinsic carriers (cm^-3)" + ) + permittivity: float = Field( + default=DEFAULT_SI_PERMITTIVITY, + ge=1.0, + description="Relative permittivity of the semiconductor", + ) + grading: Literal["abrupt", "linear"] = Field(default="abrupt") + grade_const_cm4: float | None = Field( + default=None, gt=0, description="Grade constant a = |dN/dx| (cm^-4)" + ) + + @model_validator(mode="after") + def _validate_physics(self) -> Self: + """Check grading configuration and bias range.""" + if self.grading == "linear" and self.grade_const_cm4 is None: + raise ValueError("grading='linear' requires grade_const_cm4.") + _validate_bias(self.v_reverse, self.v_bi) + return self + + @property + def v_bi(self) -> float: + """Built-in potential in volts.""" + return built_in_voltage( + self.na_cm3, + self.nd_cm3, + temperature_k=self.temperature_k, + ni_cm3=self.ni_cm3, + ) + + @property + def w_um(self) -> float: + """Total depletion width in micrometers at the configured bias.""" + return depletion_width( + self.na_cm3, + self.nd_cm3, + v_reverse=self.v_reverse, + temperature_k=self.temperature_k, + ni_cm3=self.ni_cm3, + permittivity=self.permittivity, + grading=self.grading, + grade_const_cm4=self.grade_const_cm4, + ) + + @property + def xp_um(self) -> float: + """Depletion extent spilled into the P side (micrometers).""" + xp, _xn = depletion_extents( + self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading + ) + return xp + + @property + def xn_um(self) -> float: + """Depletion extent spilled into the N side (micrometers).""" + _xp, xn = depletion_extents( + self.na_cm3, self.nd_cm3, w_um=self.w_um, grading=self.grading + ) + return xn + + @property + def c_per_area(self) -> float: + """Junction capacitance per unit area in F/m^2 (``eps_s / W``).""" + return junction_capacitance_per_area(self.permittivity, self.w_um) + + def capacitance(self, length_um: float, height_um: float) -> float: + """Absolute junction capacitance for a rectangular junction face. + + Treats the depletion strip as a parallel-plate capacitor of area + ``length x height`` filled with the depleted semiconductor: + ``C = eps_s * A / W``. + + Args: + length_um: Device length along the propagation direction (um). + height_um: Junction z-extent (um), e.g. the rib height. + + Returns: + Absolute capacitance in farads. + """ + if length_um <= 0 or height_um <= 0: + raise ValueError("length_um and height_um must be positive.") + area_m2 = length_um * height_um * 1e-12 + return float(self.c_per_area * area_m2) + + def select_mode( + self, + p_extent_um: float, + n_extent_um: float, + *, + fraction: float = JUNCTION_MODE_FRACTION, + ) -> JunctionMode: + """Auto-select the representation mode for this junction. + + Thin wrapper around :func:`select_junction_mode` using this config's + computed depletion width. + + Args: + p_extent_um: Size of the doped flank on the P side (um). + n_extent_um: Size of the doped flank on the N side (um). + fraction: Resolvability threshold fraction (~1/5 default). + + Returns: + ``"high_res"`` or ``"capacitance"``. + """ + return select_junction_mode( + self.w_um, p_extent_um, n_extent_um, fraction=fraction + ) + + def to_metadata(self) -> dict[str, Any]: + """Return a plain-dict summary of the computed junction quantities.""" + return { + "na_cm3": self.na_cm3, + "nd_cm3": self.nd_cm3, + "v_reverse": self.v_reverse, + "temperature_k": self.temperature_k, + "v_bi": self.v_bi, + "w_um": self.w_um, + "xp_um": self.xp_um, + "xn_um": self.xn_um, + "c_per_area_f_m2": self.c_per_area, + "grading": self.grading, + } + + +# --------------------------------------------------------------------------- +# Doping-profile construction (merged from the former ``doping`` module). +# --------------------------------------------------------------------------- + + +_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 + + +def _as_junction_config( + junction: PNJunctionConfig | dict[str, Any], +) -> PNJunctionConfig: + """Accept a config object or plain dict for the junction parameters.""" + if isinstance(junction, PNJunctionConfig): + return junction + return PNJunctionConfig.model_validate(junction) + + +def _add_rect( + comp: gf.Component, + *, + length: float, + y0: float, + y1: float, + gds_layer: tuple[int, int], +) -> float: + """Draw a rectangle spanning ``[y0, y1]`` and return its y-centre.""" + rect = comp << gf.c.rectangle((length, y1 - y0), layer=gds_layer) + rect.y = (y0 + y1) / 2 + return (y0 + y1) / 2 + + +def make_pn_junction_profile( + comp: gf.Component, + *, + length: float, + center_y: float, + rib_width: float, + junction: PNJunctionConfig | dict[str, Any], + p_region: tuple[str, tuple[int, int], float], + n_region: tuple[str, tuple[int, int], float], + junction_region: tuple[str, tuple[int, int]] | None = None, + zmin: float = 0.0, + zmax: float | None = None, + fmax: float = 200e9, + mode: Literal["auto", "capacitance", "high_res"] = "auto", + mode_fraction: float = JUNCTION_MODE_FRACTION, + mesh_resolution: str | float = "fine", +) -> dict[str, dict[str, Any]]: + """Build P / depletion-junction / N rib regions around ``center_y``. + + The depletion width ``W`` (and its asymmetric split ``xp``/``xn`` into + the P and N halves) comes from :class:`PNJunctionConfig` (the depletion + model in this module). + + Two representation modes are supported: + + - ``"high_res"``: three contiguous rectangles are drawn — N + ``[cy - rib_width/2, cy - xn]``, depleted-junction dielectric strip + ``[cy - xn, cy + xp]``, P ``[cy + xp, cy + rib_width/2]``. The + junction strip is registered as a patterned dielectric with a real + GDS layer so it appears on the simulation mesh. + - ``"capacitance"``: geometry is unchanged from a plain P/N split + (adjacent half-rectangles); no junction polygon is drawn and callers + apply the computed capacitance as a lumped impedance boundary instead + (see ``PalaceSimMixin.set_pn_junction``). + + With ``mode="auto"`` the choice falls out of + :func:`select_junction_mode`: the strip is + meshed only when ``W >= mode_fraction * min(P flank, N flank)``, where + each flank is ``rib_width / 2``. + + Args: + comp: gdsfactory component the rectangles are added to. + length: Rectangle length along the propagation direction (um). + center_y: Y coordinate of the metallurgical junction / rib centre. + rib_width: Full rib width (um); P occupies the upper half, N the + lower half. + junction: Depletion-model parameters + (:class:`PNJunctionConfig` or its dict form). + p_region: ``(name, gds_layer, sigma_S_per_m)`` for the P region. + n_region: ``(name, gds_layer, sigma_S_per_m)`` for the N region. + junction_region: ``(name, gds_layer)`` used to register the + depletion strip in high-res mode. Required when the selected + mode is ``"high_res"``; ignored in capacitance mode. + zmin: Bottom z of the regions (um). + zmax: Top z of the regions (um); defaults to ``zmin + 0.22``. + fmax: Upper frequency of the Drude-model validity range (Hz). + mode: ``"auto"``, ``"capacitance"`` or ``"high_res"``. + mode_fraction: Auto-mode threshold fraction (~1/5 default). + mesh_resolution: Mesh resolution assigned to the generated layers. + + Returns: + Dict with keys: + + - ``layer_specs``: ``{name: Layer}`` for every drawn region. + - ``materials``: ``{name: MaterialProperties}`` (Drude models for + P/N, plain dielectric for the junction strip). + - ``centres``: ``{role: y_centre}`` for drawn regions. + - ``junction``: computed quantities (widths, capacitance, chosen + mode and selection reason). + """ + from gsim.common.stack.extractor import Layer + + cfg = _as_junction_config(junction) + p_name, p_layer, p_sigma = p_region + n_name, n_layer, n_sigma = n_region + + ztop = 0.22 if zmax is None else zmax + if ztop <= zmin: + raise ValueError("zmax must exceed zmin.") + if length <= 0: + raise ValueError("length must be positive.") + if cfg.xp_um + cfg.xn_um > rib_width: + raise ValueError( + f"Depletion width W={cfg.w_um:.4g} um does not fit in the " + f"{rib_width:.4g} um rib." + ) + + flank_um = rib_width / 2 + if mode == "auto": + mode = select_junction_mode( + cfg.w_um, flank_um, flank_um, fraction=mode_fraction + ) + reason = ( + f"W={cfg.w_um:.4g} um vs threshold " + f"{mode_fraction * flank_um:.4g} um (= {mode_fraction} * flank)" + ) + else: + reason = f"forced by caller (mode={mode!r})" + logger.info("PN junction mode: %s (%s)", mode, reason) + + 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, float] = result["centres"] + + def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: + return Layer( + name=name, + gds_layer=gds_layer, + zmin=zmin, + zmax=ztop, + thickness=ztop - zmin, + material=name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + + xp, xn = cfg.xp_um, cfg.xn_um + + # N region: lower half, trimmed by xn when the strip is meshed. + n_y0 = center_y - flank_um + n_y1 = center_y if mode == "capacitance" else center_y - xn + centres["n"] = _add_rect( + comp, length=length, y0=n_y0, y1=n_y1, gds_layer=tuple(n_layer) + ) + layer_specs[n_name] = _doped_spec(n_name, tuple(n_layer), n_sigma) + + # P region: upper half, trimmed by xp when the strip is meshed. + p_y0 = center_y if mode == "capacitance" else center_y + xp + p_y1 = center_y + flank_um + centres["p"] = _add_rect( + comp, length=length, y0=p_y0, y1=p_y1, gds_layer=tuple(p_layer) + ) + layer_specs[p_name] = _doped_spec(p_name, tuple(p_layer), p_sigma) + + materials.update( + make_doped_materials( + [(p_name, p_sigma), (n_name, n_sigma)], + permittivity=cfg.permittivity, + fmax=fmax, + source_prefix="doped Si", + ) + ) + + if mode == "high_res": + if junction_region is None: + raise ValueError( + "mode='high_res' requires junction_region=(name, gds_layer)." + ) + j_name, j_layer = junction_region + centres["junction"] = _add_rect( + comp, + length=length, + y0=center_y - xn, + y1=center_y + xp, + gds_layer=tuple(j_layer), + ) + layer_specs[j_name] = Layer( + name=j_name, + gds_layer=tuple(j_layer), + zmin=zmin, + zmax=ztop, + thickness=ztop - zmin, + material=j_name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + # Depleted silicon has no free carriers: pure real permittivity. + materials[j_name] = MaterialProperties( + permittivity=cfg.permittivity, + dispersion_models=[], + ) + + result["junction"] = { + **cfg.to_metadata(), + "c_f": cfg.capacitance(length, ztop - zmin), + "mode": mode, + "selection_reason": reason, + } + return result + + +# --------------------------------------------------------------------------- +# Free-carrier plasma dispersion (1D complex permittivity). +# +# Minimal extraction of the optical-properties model from the +# ``semiconductor.ipynb`` notebook: the Sze depletion widths locate the +# quasi-neutral and depleted slices of a 1D cut, and the Drude-Sommerfeld +# conductivity of each carrier population gives the local complex +# permittivity at optical frequencies. +# --------------------------------------------------------------------------- + +#: Conduction-band effective mass in units of the free-electron mass +#: (Ioffe NSM silicon band structure). +M_CE_STAR: float = 0.26 + +#: Valence-band (heavy-hole) effective mass in units of ``m0``. +M_CH_STAR: float = 0.38 + +#: Default electron mobility in cm^2/(V s) (Sze, ``N_I = 2e16`` cm^-3). +MU_N_CM2_VS: float = 1000.0 + +#: Default hole mobility in cm^2/(V s) (Sze, ``N_I = 2e16`` cm^-3). +MU_P_CM2_VS: float = 450.0 + +#: Optical conductivities below this (S/m) are treated as negligible and +#: mapped to ``None`` so depleted strips stay pure dielectrics. +SIGMA_NEGLIGIBLE_SM: float = 1e-6 + + +def carrier_profile_1d( + y_um: np.ndarray | list[float] | float, + *, + center_um: float, + xp_um: float, + xn_um: float, + na_cm3: float, + nd_cm3: float, + ni_cm3: float = NI_SI_300K_CM3, +) -> tuple[np.ndarray, np.ndarray]: + """Free-carrier densities along a 1D cut through an abrupt PN junction. + + Depletion approximation: outside ``[center - xn, center + xp]`` each + side is quasi-neutral with the local-equilibrium densities for the net + doping ``C`` (``n0 = (C + sqrt(C^2 + 4 ni^2))/2``, + ``p0 = (-C + sqrt(C^2 + 4 ni^2))/2`` with ``C = +Nd`` / ``-Na``, + evaluated in the ``n*p = ni^2`` form for float64 safety); + inside the strip the carriers are swept out (``C = 0`` gives ``ni``). + This is the 1D counterpart of the notebook's ``C_Sze`` charge profile. + + Args: + y_um: Sample positions in micrometers (scalar, list or array). + center_um: Metallurgical-junction position in micrometers. + xp_um: Depletion extent into the P side (``>= 0``). + xn_um: Depletion extent into the N side (``>= 0``). + na_cm3: Acceptor concentration on the P side in cm^-3 (> 0). + nd_cm3: Donor concentration on the N side in cm^-3 (> 0). + ni_cm3: Intrinsic carrier concentration in cm^-3 (> 0). + + Returns: + ``(n_cm3, p_cm3)`` electron/hole density arrays in cm^-3 with the + broadcast shape of ``y_um``. + + Raises: + ValueError: On non-positive concentrations or negative extents. + """ + if na_cm3 <= 0 or nd_cm3 <= 0 or ni_cm3 <= 0: + raise ValueError("Doping and intrinsic concentrations must be positive.") + if xp_um < 0 or xn_um < 0: + raise ValueError("Depletion extents must be non-negative.") + y = np.asarray(y_um, dtype=float) + net = np.zeros_like(y) + net[y >= center_um + xp_um] = -na_cm3 + net[y <= center_um - xn_um] = nd_cm3 + # Majority density first, minority via n*p = ni^2: the direct + # (-C + sqrt(C^2 + 4 ni^2))/2 form cancels catastrophically in float64 + # when |C| >> ni (ulp(1e18) ~ 128 vs minority values ~1e2). + disc = np.sqrt(net**2 + 4.0 * ni_cm3**2) + majority = (np.abs(net) + disc) / 2.0 + minority = ni_cm3**2 / majority + n = np.where(net >= 0.0, majority, minority) + p = np.where(net >= 0.0, minority, majority) + return n, p + + +def drude_relaxation_times( + mu_n_cm2_vs: float = MU_N_CM2_VS, + mu_p_cm2_vs: float = MU_P_CM2_VS, +) -> tuple[float, float]: + """Drude momentum-relaxation times from mobilities (``tau = m* mu / q``). + + Args: + mu_n_cm2_vs: Electron mobility in cm^2/(V s) (> 0). + mu_p_cm2_vs: Hole mobility in cm^2/(V s) (> 0). + + Returns: + ``(tau_e_s, tau_h_s)`` relaxation times in seconds. + + Raises: + ValueError: On non-positive mobilities. + """ + if mu_n_cm2_vs <= 0 or mu_p_cm2_vs <= 0: + raise ValueError("Mobilities must be positive.") + tau_e = M_CE_STAR * M0 * (mu_n_cm2_vs * 1e-4) / Q + tau_h = M_CH_STAR * M0 * (mu_p_cm2_vs * 1e-4) / Q + return float(tau_e), float(tau_h) + + +def _optical_omega(wavelength_um: float) -> float: + """Angular frequency in rad/s for a vacuum wavelength in micrometers.""" + if wavelength_um <= 0: + raise ValueError("wavelength_um must be positive.") + return 2.0 * math.pi * C_LIGHT / (wavelength_um * 1e-6) + + +def epsilon_eff_relative( + n_cm3: np.ndarray | list[float] | float, + p_cm3: np.ndarray | list[float] | float, + *, + wavelength_um: float, + eps_bg_rel: float, + mu_n_cm2_vs: float = MU_N_CM2_VS, + mu_p_cm2_vs: float = MU_P_CM2_VS, + tau_e_s: float | None = None, + tau_h_s: float | None = None, +) -> np.ndarray: + """Complex relative permittivity from free-carrier plasma dispersion. + + Full Drude-Sommerfeld form (notebook ``effective_eps``, SI-corrected):: + + eps_r = eps_bg - [n q mu_n / (tau_e eps0) (1 - j/(w tau_e)) + + p q mu_p / (tau_h eps0) (1 - j/(w tau_h))] / w^2 + + with densities converted from cm^-3 to m^-3. At 1550 nm + ``w tau >> 1`` (relaxation regime), so the real part carries the + plasma shift and the imaginary part the free-carrier absorption. + + Args: + n_cm3: Electron density in cm^-3 (scalar or array). + p_cm3: Hole density in cm^-3 (scalar or array, broadcastable). + wavelength_um: Optical wavelength in micrometers (> 0). + eps_bg_rel: Relative permittivity of the undoped lattice at the + target wavelength (e.g. Si Sellmeier, see + :func:`default_eps_bg_rel`). + mu_n_cm2_vs: Electron mobility in cm^2/(V s). + mu_p_cm2_vs: Hole mobility in cm^2/(V s). + tau_e_s: Electron relaxation time in s (derived from ``mu_n`` + when omitted). + tau_h_s: Hole relaxation time in s (derived from ``mu_p`` + when omitted). + + Returns: + Complex relative-permittivity array. + + Raises: + ValueError: On non-positive wavelength or background permittivity. + """ + if eps_bg_rel < 1.0: + raise ValueError("eps_bg_rel must be >= 1.") + if tau_e_s is None or tau_h_s is None: + tau_e_d, tau_h_d = drude_relaxation_times(mu_n_cm2_vs, mu_p_cm2_vs) + tau_e_s = tau_e_d if tau_e_s is None else tau_e_s + tau_h_s = tau_h_d if tau_h_s is None else tau_h_s + if tau_e_s <= 0 or tau_h_s <= 0: + raise ValueError("Relaxation times must be positive.") + omega = _optical_omega(wavelength_um) + n_m3 = np.asarray(n_cm3, dtype=float) * 1e6 + p_m3 = np.asarray(p_cm3, dtype=float) * 1e6 + mu_n_si = mu_n_cm2_vs * 1e-4 + mu_p_si = mu_p_cm2_vs * 1e-4 + shift = ( + n_m3 * Q * mu_n_si / tau_e_s * (1.0 - 1j / (omega * tau_e_s)) + + p_m3 * Q * mu_p_si / tau_h_s * (1.0 - 1j / (omega * tau_h_s)) + ) / (EPS0 * omega**2) + return eps_bg_rel - shift + + +def optical_params( + eps_rel: np.ndarray | list[float] | complex, + wavelength_um: float, +) -> tuple[np.ndarray | float, np.ndarray | float]: + """Split a complex relative permittivity for Palace material entry. + + Palace carries a real ``Permittivity`` plus a ``Conductivity``, so + ``eps''`` is mapped through ``sigma = omega eps0 eps''``. + + Args: + eps_rel: Complex relative permittivity (scalar or array). + wavelength_um: Optical wavelength in micrometers (> 0). + + Returns: + ``(eps_prime, sigma_Sm)`` real part and conductivity in S/m + (Python floats for scalar input, arrays otherwise). + """ + omega = _optical_omega(wavelength_um) + eps = np.asarray(eps_rel, dtype=complex) + prime = np.real(eps) + sigma = omega * EPS0 * np.imag(eps) + if eps.ndim == 0: + return float(prime), float(sigma) + return prime, sigma + + +def refractive_index( + eps_rel: np.ndarray | list[float] | complex, +) -> tuple[np.ndarray | float, np.ndarray | float]: + """Refractive index and extinction coefficient from ``n + jk = sqrt(eps)``. + + Args: + eps_rel: Complex relative permittivity (scalar or array). + + Returns: + ``(n, k)`` index and extinction (floats for scalar input). + """ + m = np.sqrt(np.asarray(eps_rel, dtype=complex)) + if m.ndim == 0: + return float(np.real(m)), float(np.imag(m)) + return np.real(m), np.imag(m) + + +def default_eps_bg_rel( + wavelength_um: float, + material: str = "silicon", +) -> float: + """Lattice background permittivity for the Drude model at ``wavelength``. + + Resolves the undoped ``material`` dispersion model (e.g. Si Sellmeier) + at the target wavelength; falls back to + :data:`DEFAULT_SI_PERMITTIVITY` when the database has no value. + + Args: + wavelength_um: Optical wavelength in micrometers (> 0). + material: Undoped material name in the materials database. + + Returns: + Relative background permittivity as a float. + """ + from gsim.common.stack.materials import resolve_material_at_wavelength + + resolved = resolve_material_at_wavelength(material, wavelength_um) + if resolved is not None and resolved.permittivity_scalar is not None: + return float(resolved.permittivity_scalar) + return DEFAULT_SI_PERMITTIVITY + + +def junction_epsilon_profile( + y_um: np.ndarray | list[float], + junction: PNJunctionConfig | dict[str, Any], + *, + center_um: float = 0.0, + wavelength_um: float = 1.55, + eps_bg_rel: float | None = None, + mu_n_cm2_vs: float = MU_N_CM2_VS, + mu_p_cm2_vs: float = MU_P_CM2_VS, +) -> dict[str, Any]: + """1D Sze-based complex permittivity across a PN junction. + + Combines the depletion model (:class:`PNJunctionConfig` gives + ``xp``/``xn``) with the 1D carrier profile and Drude dispersion, so a + single call maps positions to the complex permittivity that each + slice of a segmented waveguide should carry. + + Args: + y_um: Sample positions in micrometers (list or array). + junction: Depletion-model parameters (config or its dict form). + center_um: Metallurgical-junction position in micrometers. + wavelength_um: Optical wavelength in micrometers (> 0). + eps_bg_rel: Lattice background (resolved from the Si Sellmeier + model at ``wavelength_um`` when omitted). + mu_n_cm2_vs: Electron mobility in cm^2/(V s). + mu_p_cm2_vs: Hole mobility in cm^2/(V s). + + Returns: + Dict with ``y_um``, ``n_cm3``, ``p_cm3``, ``eps_rel``, + ``eps_prime``, ``sigma_Sm``, ``n_index``, ``k_index``, + ``eps_bg_rel`` and the ``junction`` metadata. + """ + cfg = _as_junction_config(junction) + y = np.asarray(y_um, dtype=float) + n, p = carrier_profile_1d( + y, + center_um=center_um, + xp_um=cfg.xp_um, + xn_um=cfg.xn_um, + na_cm3=cfg.na_cm3, + nd_cm3=cfg.nd_cm3, + ni_cm3=cfg.ni_cm3, + ) + bg = default_eps_bg_rel(wavelength_um) if eps_bg_rel is None else eps_bg_rel + eps = epsilon_eff_relative( + n, + p, + wavelength_um=wavelength_um, + eps_bg_rel=bg, + mu_n_cm2_vs=mu_n_cm2_vs, + mu_p_cm2_vs=mu_p_cm2_vs, + ) + prime, sigma = optical_params(eps, wavelength_um) + n_index, k_index = refractive_index(eps) + return { + "y_um": y, + "n_cm3": n, + "p_cm3": p, + "eps_rel": eps, + "eps_prime": prime, + "sigma_Sm": sigma, + "n_index": n_index, + "k_index": k_index, + "eps_bg_rel": bg, + "junction": cfg.to_metadata(), + } + + +# --------------------------------------------------------------------------- +# Segmented-junction geometry (fine bins carrying the 1D permittivity). +# --------------------------------------------------------------------------- + + +def make_segmented_junction_profile( + comp: gf.Component, + *, + length: float, + center_y: float, + rib_width: float, + junction: PNJunctionConfig | dict[str, Any], + n_p: int, + n_n: int, + wavelength_um: float = 1.55, + eps_bg_rel: float | None = None, + mu_n_cm2_vs: float = MU_N_CM2_VS, + mu_p_cm2_vs: float = MU_P_CM2_VS, + p_prefix: str = "p_", + n_prefix: str = "n_", + p_gds_start: tuple[int, int] = (21, 1), + n_gds_start: tuple[int, int] = (20, 1), + zmin: float = 0.0, + zmax: float | None = None, + mesh_resolution: str | float = "fine", +) -> dict[str, dict[str, Any]]: + """Bin the rib into fine strips sampling the 1D Sze permittivity. + + The P half ``[center_y, center_y + rib_width/2]`` is split into ``n_p`` + uniform strips (``p_1`` at the metallurgical junction, ``p_{n_p}`` at + the rib edge) and the N half mirrored into ``n_n`` strips (``n_1`` at + the junction). Each strip gets its own GDS layer and material whose + ``(eps', sigma)`` comes from :func:`junction_epsilon_profile` sampled + at the strip centre, so an optical eigenmode sees the laterally varying + free-carrier permittivity instead of a homogeneous body. Strips whose + centres fall inside the depletion slice sample ``~ni`` and stay pure + dielectrics (``conductivity=None``). + + Unlike :func:`make_pn_junction_profile` there is no capacitance mode: + the bins always resolve whatever depletion width the bias point gives. + + Args: + comp: gdsfactory component the rectangles are added to. + length: Rectangle length along the propagation direction (um). + center_y: Y coordinate of the metallurgical junction / rib centre. + rib_width: Full rib width (um). + junction: Depletion-model parameters (config or its dict form). + n_p: Strip count on the P side (>= 1). + n_n: Strip count on the N side (>= 1). + wavelength_um: Optical wavelength in micrometers (> 0). + eps_bg_rel: Lattice background (resolved from the Si Sellmeier + model at ``wavelength_um`` when omitted). + mu_n_cm2_vs: Electron mobility in cm^2/(V s). + mu_p_cm2_vs: Hole mobility in cm^2/(V s). + p_prefix: Name prefix for P-side strips. + n_prefix: Name prefix for N-side strips. + p_gds_start: ``(layer, datatype)`` of ``p_1``; datatype increments + per strip. Must not collide with other drawn layers. + n_gds_start: ``(layer, datatype)`` of ``n_1``. + zmin: Bottom z of the strips (um). + zmax: Top z of the strips (um); defaults to ``zmin + 0.22``. + mesh_resolution: Mesh resolution assigned to the layers. + + Returns: + Dict with keys ``layer_specs`` (``{name: Layer}``), + ``materials`` (``{name: MaterialProperties}`` with per-strip + ``permittivity``/``conductivity``), ``centres`` + (``{name: y_centre}``), ``segments`` (per-strip geometry plus + sampled ``n``/``p``/``eps_prime``/``sigma_Sm``) and ``junction`` + (depletion metadata plus binning parameters). + + Raises: + ValueError: On invalid counts, geometry, or a depletion width + wider than the rib. + """ + from gsim.common.stack.extractor import Layer + + cfg = _as_junction_config(junction) + if not isinstance(n_p, int) or not isinstance(n_n, int) or n_p < 1 or n_n < 1: + raise ValueError("n_p and n_n must be positive integers.") + ztop = 0.22 if zmax is None else zmax + if ztop <= zmin: + raise ValueError("zmax must exceed zmin.") + if length <= 0: + raise ValueError("length must be positive.") + if cfg.xp_um + cfg.xn_um > rib_width: + raise ValueError( + f"Depletion width W={cfg.w_um:.4g} um does not fit in the " + f"{rib_width:.4g} um rib." + ) + + half = rib_width / 2.0 + # (name, y0, y1, gds, side) ordered junction-outward on each side. + p_edges = np.linspace(center_y, center_y + half, n_p + 1) + n_edges = np.linspace(center_y - half, center_y, n_n + 1) + strips: list[tuple[str, float, float, tuple[int, int], str]] = [ + ( + f"{p_prefix}{i + 1}", + float(p_edges[i]), + float(p_edges[i + 1]), + (p_gds_start[0], p_gds_start[1] + i), + "p", + ) + for i in range(n_p) + ] + strips += [ + ( + f"{n_prefix}{i + 1}", + float(n_edges[n_n - i - 1]), + float(n_edges[n_n - i]), + (n_gds_start[0], n_gds_start[1] + i), + "n", + ) + for i in range(n_n) + ] + + centres_y = np.array([(y0 + y1) / 2.0 for _, y0, y1, _, _ in strips]) + prof = junction_epsilon_profile( + centres_y, + cfg, + center_um=center_y, + wavelength_um=wavelength_um, + eps_bg_rel=eps_bg_rel, + mu_n_cm2_vs=mu_n_cm2_vs, + mu_p_cm2_vs=mu_p_cm2_vs, + ) + + result: dict[str, dict[str, Any]] = { + "layer_specs": {}, + "materials": {}, + "centres": {}, + "segments": {}, + } + layer_specs = cast("dict[str, Layer]", result["layer_specs"]) + materials: dict[str, Any] = result["materials"] + centres: dict[str, float] = result["centres"] + segments: dict[str, Any] = result["segments"] + + for k, (name, y0, y1, gds_layer, side) in enumerate(strips): + yc = centres[name] = _add_rect( + comp, length=length, y0=y0, y1=y1, gds_layer=gds_layer + ) + layer_specs[name] = Layer( + name=name, + gds_layer=gds_layer, + zmin=zmin, + zmax=ztop, + thickness=ztop - zmin, + material=name, + layer_type="dielectric", + mesh_resolution=mesh_resolution, + ) + eps_prime = float(prof["eps_prime"][k]) + sigma = float(prof["sigma_Sm"][k]) + materials[name] = MaterialProperties( + permittivity=eps_prime, + conductivity=sigma if sigma >= SIGMA_NEGLIGIBLE_SM else None, + ) + segments[name] = { + "y0_um": y0, + "y1_um": y1, + "yc_um": yc, + "gds_layer": gds_layer, + "side": side, + "n_cm3": float(prof["n_cm3"][k]), + "p_cm3": float(prof["p_cm3"][k]), + "eps_prime": eps_prime, + "sigma_Sm": sigma, + } + + result["junction"] = { + **cfg.to_metadata(), + "mode": "segmented", + "wavelength_um": wavelength_um, + "eps_bg_rel": prof["eps_bg_rel"], + "n_p": n_p, + "n_n": n_n, + } + return result diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index c310a1ba..2ba2b13a 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -441,14 +441,14 @@ def set_pn_junction( Capacitance-mode modelling of a PN junction: the depletion width ``W`` is computed from the doping concentrations and bias point via - :class:`gsim.common.stack.junction.PNJunctionConfig` (Sze, + :class:`gsim.common.stack.pn_junction.PNJunctionConfig` (Sze, *Physics of Semiconductor Devices*, ch. 2), converted to an absolute parallel-plate capacitance ``C = eps_s * A / W``, and applied as a lumped Impedance boundary on the shared P/N interface. Use this when the depletion strip is too thin to resolve on the mesh (the auto-selection in - :func:`gsim.common.stack.doping.make_pn_junction_profile` picks this + :func:`gsim.common.stack.pn_junction.make_pn_junction_profile` picks this regime); for well-resolved depletion regions prefer drawing them as dielectric geometry (``mode="high_res"``) instead. @@ -473,7 +473,7 @@ def set_pn_junction( ... height_um=0.22, ... ) """ - from gsim.common.stack.junction import PNJunctionConfig + from gsim.common.stack.pn_junction import PNJunctionConfig cfg = ( junction diff --git a/tests/common/test_cross_section.py b/tests/common/test_cross_section.py index 2bc04e56..261d4e67 100644 --- a/tests/common/test_cross_section.py +++ b/tests/common/test_cross_section.py @@ -19,7 +19,7 @@ extract_xz_rectangles, extract_yz_rectangles, ) -from gsim.common.stack.doping import make_doping_profile +from gsim.common.stack.pn_junction import make_doping_profile def _layer( diff --git a/tests/common/test_junction_physics.py b/tests/common/test_junction_physics.py deleted file mode 100644 index 1b830b31..00000000 --- a/tests/common/test_junction_physics.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Tests for the PN-junction depletion physics (Sze ch. 2 formulas). - -The expected values are recomputed here from the textbook expressions with -scipy.constants so the tests validate the wiring independently of the -implementation internals. -""" - -from __future__ import annotations - -import math - -import pytest -from pydantic import ValidationError -from scipy.constants import Boltzmann as KB # noqa: N814 -from scipy.constants import elementary_charge as Q # noqa: N812 -from scipy.constants import epsilon_0 as EPS0 # noqa: N812 - -from gsim.common.stack.junction import ( - PNJunctionConfig, - built_in_voltage, - depletion_extents, - depletion_width, - junction_capacitance_per_area, - select_junction_mode, -) - -VT_300 = KB * 300.0 / Q - - -class TestBuiltInVoltage: - def test_symmetric_silicon_value(self): - v_bi = built_in_voltage(1e19, 1e19) - expected = VT_300 * math.log(1e38 / (1.5e10) ** 2) - assert v_bi == pytest.approx(expected, rel=1e-12) - assert v_bi == pytest.approx(1.05, abs=0.03) - - def test_temperature_dependence(self): - cold = built_in_voltage(1e18, 1e18, temperature_k=250.0) - hot = built_in_voltage(1e18, 1e18, temperature_k=350.0) - expected_cold = KB * 250.0 / Q * math.log(1e36 / (1.5e10) ** 2) - expected_hot = KB * 350.0 / Q * math.log(1e36 / (1.5e10) ** 2) - assert cold == pytest.approx(expected_cold, rel=1e-12) - assert hot == pytest.approx(expected_hot, rel=1e-12) - - def test_rejects_nonphysical_inputs(self): - with pytest.raises(ValueError): - built_in_voltage(-1e18, 1e18) - with pytest.raises(ValueError): - built_in_voltage(1e18, 0.0) - with pytest.raises(ValueError): - built_in_voltage(1e18, 1e18, temperature_k=0.0) - - def test_rejects_degenerate_doping(self): - with pytest.raises(ValueError, match="ni"): - built_in_voltage(1e9, 1e9) - - -class TestDepletionWidthAbrupt: - def test_symmetric_hand_check(self): - w_um = depletion_width(1e18, 1e18) - na_m3 = nd_m3 = 1e18 * 1e6 - eps_s = 11.9 * EPS0 - expected_m = math.sqrt( - 2 - * eps_s - * VT_300 - * math.log(1e36 / (1.5e10) ** 2) - / Q - * (na_m3 + nd_m3) - / (na_m3 * nd_m3) - ) - assert w_um == pytest.approx(expected_m * 1e6, rel=1e-12) - - def test_reverse_bias_sqrt_scaling(self): - w0 = depletion_width(1e19, 5e17) - vbi = built_in_voltage(1e19, 5e17) - w_r = depletion_width(1e19, 5e17, v_reverse=2.0) - assert w_r / w0 == pytest.approx(math.sqrt((vbi + 2.0) / vbi), rel=1e-12) - - def test_one_sided_limit(self): - # NA >> ND: nearly all the depletion spills into the lightly doped side. - w = depletion_width(1e20, 1e17) - xp, xn = depletion_extents(1e20, 1e17, w_um=w) - assert xn == pytest.approx(w, rel=1e-3) - assert xp == pytest.approx(w * 1e-3, rel=1e-2) - - def test_forward_bias_below_flatband(self): - vbi = built_in_voltage(1e18, 1e18) - w_eq = depletion_width(1e18, 1e18) - w_fw = depletion_width(1e18, 1e18, v_reverse=-vbi / 2) - assert w_fw < w_eq - with pytest.raises(ValueError, match="flat-band"): - depletion_width(1e18, 1e18, v_reverse=-(vbi + 0.01)) - - -class TestDepletionWidthGraded: - def test_cubic_root_law(self): - a_cm4 = 1e21 - vbi = built_in_voltage(1e18, 1e18) - w = depletion_width(1e18, 1e18, grading="linear", grade_const_cm4=a_cm4) - eps_s = 11.9 * EPS0 - expected_m = (12 * eps_s * vbi / (Q * a_cm4 * 1e6)) ** (1 / 3) - assert w == pytest.approx(expected_m * 1e6, rel=1e-12) - - def test_graded_bias_scaling(self): - kwargs = dict(grading="linear", grade_const_cm4=1e21) - w0 = depletion_width(1e18, 1e18, **kwargs) - w_r = depletion_width(1e18, 1e18, v_reverse=1.0, **kwargs) - vbi = built_in_voltage(1e18, 1e18) - assert w_r / w0 == pytest.approx(((vbi + 1.0) / vbi) ** (1 / 3), rel=1e-12) - - def test_graded_is_symmetric(self): - w = depletion_width(1e18, 1e19, grading="linear", grade_const_cm4=1e20) - xp, xn = depletion_extents(1e18, 1e19, w_um=w, grading="linear") - assert xp == pytest.approx(w / 2) - assert xn == pytest.approx(w / 2) - - def test_requires_grade_constant(self): - with pytest.raises(ValueError, match="grade_const"): - depletion_width(1e18, 1e18, grading="linear") - - def test_unknown_grading(self): - with pytest.raises(ValueError, match="grading"): - depletion_width(1e18, 1e18, grading="exponential") # type: ignore[arg-type] - - -class TestCapacitance: - def test_per_area_inverse_w(self): - eps_r = 11.9 - for w_um in (0.01, 0.05, 0.2): - c = junction_capacitance_per_area(eps_r, w_um) - assert c == pytest.approx(eps_r * EPS0 / (w_um * 1e-6), rel=1e-12) - - def test_absolute_capacitance(self): - junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) - c = junc.capacitance(length_um=10.0, height_um=0.22) - area_m2 = 10.0 * 0.22 * 1e-12 - assert c == pytest.approx(junc.c_per_area * area_m2, rel=1e-12) - # Same order as typical TW-MZM junction caps (~fF per 10 um). - assert 1e-15 < c < 1e-13 - - def test_capacitance_scales_with_bias(self): - junc0 = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) - junc_r = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=3.0) - assert junc_r.capacitance(10.0, 0.22) < junc0.capacitance(10.0, 0.22) - - -class TestSelectJunctionMode: - def test_comparable_width_selects_high_res(self): - assert select_junction_mode(0.05, 0.2, 0.2) == "high_res" - assert select_junction_mode(0.0401, 0.2, 0.2) == "high_res" - - def test_too_thin_selects_capacitance(self): - assert select_junction_mode(0.0166, 0.2, 0.2) == "capacitance" - assert select_junction_mode(0.0399, 0.2, 0.2) == "capacitance" - - def test_threshold_is_fraction_of_smaller_flank(self): - assert select_junction_mode(0.0099, 0.05, 0.4, fraction=0.2) == "capacitance" - assert select_junction_mode(0.0101, 0.05, 0.4, fraction=0.2) == "high_res" - - def test_custom_fraction(self): - assert select_junction_mode(0.09, 0.2, 0.2, fraction=0.5) == "capacitance" - assert select_junction_mode(0.11, 0.2, 0.2, fraction=0.5) == "high_res" - - def test_invalid_inputs(self): - with pytest.raises(ValueError): - select_junction_mode(0.0, 0.2, 0.2) - with pytest.raises(ValueError): - select_junction_mode(0.1, 0.0, 0.2) - with pytest.raises(ValueError): - select_junction_mode(0.1, 0.2, 0.2, fraction=1.5) - - -class TestPNJunctionConfig: - def test_derived_quantities_consistent(self): - cfg = PNJunctionConfig(na_cm3=2e18, nd_cm3=8e18, v_reverse=0.5) - assert cfg.v_bi == pytest.approx(built_in_voltage(2e18, 8e18)) - assert cfg.w_um == pytest.approx( - depletion_width(2e18, 8e18, v_reverse=0.5), rel=1e-12 - ) - total = cfg.xp_um + cfg.xn_um - assert total == pytest.approx(cfg.w_um, rel=1e-12) - # Asymmetric split: more depletion on the lighter-doped side. - assert cfg.xp_um > cfg.xn_um - - def test_dict_construction(self): - cfg = PNJunctionConfig.model_validate({"na_cm3": 1e19, "nd_cm3": 1e19}) - assert cfg.na_cm3 == 1e19 - - def test_linear_requires_grade_const(self): - with pytest.raises(ValidationError, match="grade_const"): - PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, grading="linear") - - def test_rejects_beyond_flatband(self): - vbi = built_in_voltage(1e18, 1e18) - with pytest.raises(ValidationError): - PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=-vbi - 0.05) - - def test_rejects_bad_concentrations(self): - with pytest.raises(ValidationError): - PNJunctionConfig(na_cm3=0.0, nd_cm3=1e18) - - def test_to_metadata_keys(self): - meta = PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18).to_metadata() - for key in ( - "na_cm3", - "nd_cm3", - "v_bi", - "w_um", - "xp_um", - "xn_um", - "c_per_area_f_m2", - "grading", - ): - assert key in meta diff --git a/tests/common/test_junction_profile.py b/tests/common/test_junction_profile.py deleted file mode 100644 index 9a5f3814..00000000 --- a/tests/common/test_junction_profile.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests for ``make_pn_junction_profile`` geometry, materials and mode selection.""" - -from __future__ import annotations - -import logging -from typing import cast - -import gdsfactory as gf -import pytest - -from gsim.common.cross_section import RectYZ2D, extract_plane_section -from gsim.common.stack.doping import make_pn_junction_profile -from gsim.common.stack.extractor import LayerStack -from gsim.common.stack.junction import PNJunctionConfig - -CY = -20.0 -RIB_WIDTH = 0.4 -LENGTH = 10.0 - -P_REGION = ("p_rib", (21, 0), 1.6e3) -N_REGION = ("n_rib", (20, 0), 1.6e3) -JUNCTION_REGION = ("junction", (22, 0)) - - -def _thin_junction() -> PNJunctionConfig: - """Na=Nd=1e19 cm^-3 at zero bias -> W ~ 17 nm < threshold.""" - return PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) - - -def _wide_junction() -> PNJunctionConfig: - """Light doping + reverse bias -> W ~ 71 nm > threshold (40 nm).""" - return PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=1.0) - - -def _build(junction, **kwargs): - comp = gf.Component() - kwargs.setdefault("p_region", P_REGION) - kwargs.setdefault("n_region", N_REGION) - kwargs.setdefault("zmin", 0.0) - kwargs.setdefault("zmax", 0.22) - result = make_pn_junction_profile( - comp, - length=LENGTH, - center_y=CY, - rib_width=RIB_WIDTH, - junction=junction, - **kwargs, - ) - return comp, result - - -def _section_rects(comp, result): - """Extract the x=0 plane section from a profile-built component.""" - stack = LayerStack(pdk_name="test") - stack.layers.update(result["layer_specs"]) - for name, mat in result["materials"].items(): - stack.materials[name] = mat.to_dict() - rects = extract_plane_section(comp.copy(), stack, axis="x", value=0.0) - # axis="x" always yields YZ rectangles; narrow the union for attribute access. - return sorted(cast("list[RectYZ2D]", rects), key=lambda r: r.y0) - - -class TestAutoModeSelection: - def test_thin_junction_selects_capacitance(self): - _comp, res = _build(_thin_junction()) - assert res["junction"]["mode"] == "capacitance" - assert "threshold" in res["junction"]["selection_reason"] - - def test_wide_junction_selects_high_res(self): - _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) - assert res["junction"]["mode"] == "high_res" - - def test_auto_logs_selection_reason(self, caplog): - with caplog.at_level(logging.INFO, logger="gsim.common.stack.doping"): - _comp, _res = _build(_thin_junction()) - assert any("capacitance" in rec.message for rec in caplog.records) - - def test_forced_mode_overrides_auto(self): - _comp, res = _build( - _thin_junction(), mode="high_res", junction_region=JUNCTION_REGION - ) - assert res["junction"]["mode"] == "high_res" - assert "forced" in res["junction"]["selection_reason"] - _comp, res = _build(_wide_junction(), mode="capacitance") - assert res["junction"]["mode"] == "capacitance" - - -class TestCapacitanceModeGeometry: - def test_no_junction_polygon_or_spec(self): - comp, res = _build(_thin_junction()) - assert "junction" not in res["layer_specs"] - assert "junction" not in res["materials"] - # No polygon may exist on the junction GDS layer. - polys = comp.get_polygons(layers=(JUNCTION_REGION[1],)) - assert not any(v for v in polys.values()) - - def test_p_n_adjacent_halves(self): - comp, res = _build(_thin_junction()) - rects = _section_rects(comp, res) - names = [r.layer_name for r in rects] - assert set(names) == {"p_rib", "n_rib"} - by_name = {r.layer_name: r for r in rects} - assert by_name["p_rib"].y0 == pytest.approx(CY) - assert by_name["n_rib"].y1 == pytest.approx(CY) - - def test_junction_metadata_present(self): - junc = _thin_junction() - _comp, res = _build(junc) - meta = res["junction"] - assert meta["w_um"] == pytest.approx(junc.w_um) - assert meta["c_f"] == pytest.approx(junc.capacitance(LENGTH, 0.22)) - assert meta["xp_um"] + meta["xn_um"] == pytest.approx(meta["w_um"]) - - -class TestHighResModeGeometry: - def test_three_contiguous_regions(self): - comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) - rects = _section_rects(comp, res) - names = [r.layer_name for r in rects] - assert names == ["n_rib", "junction", "p_rib"] - - n_r, j_r, p_r = rects - # Contiguity with no gaps or overlaps. - assert n_r.y1 == pytest.approx(j_r.y0) - assert j_r.y1 == pytest.approx(p_r.y0) - - junc = _wide_junction() - # Depletion strip spans [cy - xn, cy + xp] (within layout DBU rounding). - assert j_r.y0 == pytest.approx(CY - junc.xn_um, abs=2e-3) - assert j_r.y1 == pytest.approx(CY + junc.xp_um, abs=2e-3) - assert (j_r.y1 - j_r.y0) == pytest.approx(junc.w_um, abs=4e-3) - # Flanks fill the rest of the rib. - assert (p_r.y1 - p_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xp_um, abs=4e-3) - assert (n_r.y1 - n_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xn_um, abs=4e-3) - # Full rib span is covered exactly once. - assert p_r.y1 - n_r.y0 == pytest.approx(RIB_WIDTH) - - def test_material_models(self): - _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) - # Doped regions carry Drude conductivity. - for name in ("p_rib", "n_rib"): - mat = res["materials"][name] - assert mat.conductivity == pytest.approx(1.6e3) - assert mat.permittivity == pytest.approx(11.9) - # Junction strip: depleted silicon -> pure real permittivity, no carriers. - jmat = res["materials"]["junction"] - assert jmat.permittivity == pytest.approx(11.9) - assert jmat.conductivity is None - assert jmat.dispersion_models == [] - - def test_high_res_requires_junction_region(self): - with pytest.raises(ValueError, match="junction_region"): - _build(_wide_junction()) - - def test_layer_specs_reference_materials(self): - _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) - for name in ("p_rib", "n_rib", "junction"): - spec = res["layer_specs"][name] - assert spec.material == name - assert spec.zmin == 0.0 - assert spec.zmax == 0.22 - - -class TestValidation: - def test_depletion_wider_than_rib_rejected(self): - big = PNJunctionConfig(na_cm3=1e16, nd_cm3=1e16, v_reverse=5.0) - if big.w_um <= RIB_WIDTH: - pytest.skip("picked parameters do not exceed the rib width") - with pytest.raises(ValueError, match="fit"): - _build(big) - - def test_invalid_zmax_rejected(self): - with pytest.raises(ValueError): - _build(_thin_junction(), zmax=-1.0) - - def test_accepts_dict_junction_config(self): - _comp, res = _build( - {"na_cm3": 1e18, "nd_cm3": 1e18, "v_reverse": 1.0}, - junction_region=JUNCTION_REGION, - ) - assert res["junction"]["w_um"] == pytest.approx(_wide_junction().w_um) - assert res["junction"]["mode"] == "high_res" diff --git a/tests/common/test_pn_junction.py b/tests/common/test_pn_junction.py new file mode 100644 index 00000000..3aefdaa0 --- /dev/null +++ b/tests/common/test_pn_junction.py @@ -0,0 +1,881 @@ +"""Tests for the consolidated PN-junction stack module. + +Covers ``gsim.common.stack.pn_junction`` end to end: + +- Part 1: depletion model (Sze ch. 2 formulas, validated against textbook + expressions recomputed here with scipy.constants). +- Part 2: ``make_pn_junction_profile`` geometry, materials and mode selection. +- Part 3: Palace capacitance vs high-res mesh representation. +- Part 4: 1D free-carrier plasma dispersion (complex optical permittivity). +- Part 5: ``make_segmented_junction_profile`` strips and their Palace config. +""" + +from __future__ import annotations + +import json +import logging +import math +from itertools import pairwise +from pathlib import Path +from typing import cast + +import gdsfactory as gf +import numpy as np +import pytest +from pydantic import ValidationError +from scipy.constants import Boltzmann as KB # noqa: N814 +from scipy.constants import elementary_charge as Q # noqa: N812 +from scipy.constants import epsilon_0 as EPS0 # noqa: N812 + +from gsim.common.cross_section import ( + RectYZ2D, + build_doped_cross_section, + build_optical_cross_section, + extract_plane_section, +) +from gsim.common.stack.extractor import LayerStack +from gsim.common.stack.pn_junction import ( + NI_SI_300K_CM3, + PNJunctionConfig, + built_in_voltage, + carrier_profile_1d, + depletion_extents, + depletion_width, + drude_relaxation_times, + epsilon_eff_relative, + junction_capacitance_per_area, + junction_epsilon_profile, + make_pn_junction_profile, + make_segmented_junction_profile, + optical_params, + refractive_index, + select_junction_mode, +) +from gsim.palace import BoundaryModeSim + +# --------------------------------------------------------------------------- +# Part 1: depletion model. +# --------------------------------------------------------------------------- + + +VT_300 = KB * 300.0 / Q + + +class TestBuiltInVoltage: + def test_symmetric_silicon_value(self): + v_bi = built_in_voltage(1e19, 1e19) + expected = VT_300 * math.log(1e38 / (1.5e10) ** 2) + assert v_bi == pytest.approx(expected, rel=1e-12) + assert v_bi == pytest.approx(1.05, abs=0.03) + + def test_temperature_dependence(self): + cold = built_in_voltage(1e18, 1e18, temperature_k=250.0) + hot = built_in_voltage(1e18, 1e18, temperature_k=350.0) + expected_cold = KB * 250.0 / Q * math.log(1e36 / (1.5e10) ** 2) + expected_hot = KB * 350.0 / Q * math.log(1e36 / (1.5e10) ** 2) + assert cold == pytest.approx(expected_cold, rel=1e-12) + assert hot == pytest.approx(expected_hot, rel=1e-12) + + def test_rejects_nonphysical_inputs(self): + with pytest.raises(ValueError): + built_in_voltage(-1e18, 1e18) + with pytest.raises(ValueError): + built_in_voltage(1e18, 0.0) + with pytest.raises(ValueError): + built_in_voltage(1e18, 1e18, temperature_k=0.0) + + def test_rejects_degenerate_doping(self): + with pytest.raises(ValueError, match="ni"): + built_in_voltage(1e9, 1e9) + + +class TestDepletionWidthAbrupt: + def test_symmetric_hand_check(self): + w_um = depletion_width(1e18, 1e18) + na_m3 = nd_m3 = 1e18 * 1e6 + eps_s = 11.9 * EPS0 + expected_m = math.sqrt( + 2 + * eps_s + * VT_300 + * math.log(1e36 / (1.5e10) ** 2) + / Q + * (na_m3 + nd_m3) + / (na_m3 * nd_m3) + ) + assert w_um == pytest.approx(expected_m * 1e6, rel=1e-12) + + def test_reverse_bias_sqrt_scaling(self): + w0 = depletion_width(1e19, 5e17) + vbi = built_in_voltage(1e19, 5e17) + w_r = depletion_width(1e19, 5e17, v_reverse=2.0) + assert w_r / w0 == pytest.approx(math.sqrt((vbi + 2.0) / vbi), rel=1e-12) + + def test_one_sided_limit(self): + # NA >> ND: nearly all the depletion spills into the lightly doped side. + w = depletion_width(1e20, 1e17) + xp, xn = depletion_extents(1e20, 1e17, w_um=w) + assert xn == pytest.approx(w, rel=1e-3) + assert xp == pytest.approx(w * 1e-3, rel=1e-2) + + def test_forward_bias_below_flatband(self): + vbi = built_in_voltage(1e18, 1e18) + w_eq = depletion_width(1e18, 1e18) + w_fw = depletion_width(1e18, 1e18, v_reverse=-vbi / 2) + assert w_fw < w_eq + with pytest.raises(ValueError, match="flat-band"): + depletion_width(1e18, 1e18, v_reverse=-(vbi + 0.01)) + + +class TestDepletionWidthGraded: + def test_cubic_root_law(self): + a_cm4 = 1e21 + vbi = built_in_voltage(1e18, 1e18) + w = depletion_width(1e18, 1e18, grading="linear", grade_const_cm4=a_cm4) + eps_s = 11.9 * EPS0 + expected_m = (12 * eps_s * vbi / (Q * a_cm4 * 1e6)) ** (1 / 3) + assert w == pytest.approx(expected_m * 1e6, rel=1e-12) + + def test_graded_bias_scaling(self): + kwargs = dict(grading="linear", grade_const_cm4=1e21) + w0 = depletion_width(1e18, 1e18, **kwargs) + w_r = depletion_width(1e18, 1e18, v_reverse=1.0, **kwargs) + vbi = built_in_voltage(1e18, 1e18) + assert w_r / w0 == pytest.approx(((vbi + 1.0) / vbi) ** (1 / 3), rel=1e-12) + + def test_graded_is_symmetric(self): + w = depletion_width(1e18, 1e19, grading="linear", grade_const_cm4=1e20) + xp, xn = depletion_extents(1e18, 1e19, w_um=w, grading="linear") + assert xp == pytest.approx(w / 2) + assert xn == pytest.approx(w / 2) + + def test_requires_grade_constant(self): + with pytest.raises(ValueError, match="grade_const"): + depletion_width(1e18, 1e18, grading="linear") + + def test_unknown_grading(self): + with pytest.raises(ValueError, match="grading"): + depletion_width(1e18, 1e18, grading="exponential") # type: ignore[arg-type] + + +class TestCapacitance: + def test_per_area_inverse_w(self): + eps_r = 11.9 + for w_um in (0.01, 0.05, 0.2): + c = junction_capacitance_per_area(eps_r, w_um) + assert c == pytest.approx(eps_r * EPS0 / (w_um * 1e-6), rel=1e-12) + + def test_absolute_capacitance(self): + junc = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + c = junc.capacitance(length_um=10.0, height_um=0.22) + area_m2 = 10.0 * 0.22 * 1e-12 + assert c == pytest.approx(junc.c_per_area * area_m2, rel=1e-12) + # Same order as typical TW-MZM junction caps (~fF per 10 um). + assert 1e-15 < c < 1e-13 + + def test_capacitance_scales_with_bias(self): + junc0 = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + junc_r = PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19, v_reverse=3.0) + assert junc_r.capacitance(10.0, 0.22) < junc0.capacitance(10.0, 0.22) + + +class TestSelectJunctionMode: + def test_comparable_width_selects_high_res(self): + assert select_junction_mode(0.05, 0.2, 0.2) == "high_res" + assert select_junction_mode(0.0401, 0.2, 0.2) == "high_res" + + def test_too_thin_selects_capacitance(self): + assert select_junction_mode(0.0166, 0.2, 0.2) == "capacitance" + assert select_junction_mode(0.0399, 0.2, 0.2) == "capacitance" + + def test_threshold_is_fraction_of_smaller_flank(self): + assert select_junction_mode(0.0099, 0.05, 0.4, fraction=0.2) == "capacitance" + assert select_junction_mode(0.0101, 0.05, 0.4, fraction=0.2) == "high_res" + + def test_custom_fraction(self): + assert select_junction_mode(0.09, 0.2, 0.2, fraction=0.5) == "capacitance" + assert select_junction_mode(0.11, 0.2, 0.2, fraction=0.5) == "high_res" + + def test_invalid_inputs(self): + with pytest.raises(ValueError): + select_junction_mode(0.0, 0.2, 0.2) + with pytest.raises(ValueError): + select_junction_mode(0.1, 0.0, 0.2) + with pytest.raises(ValueError): + select_junction_mode(0.1, 0.2, 0.2, fraction=1.5) + + +class TestPNJunctionConfig: + def test_derived_quantities_consistent(self): + cfg = PNJunctionConfig(na_cm3=2e18, nd_cm3=8e18, v_reverse=0.5) + assert cfg.v_bi == pytest.approx(built_in_voltage(2e18, 8e18)) + assert cfg.w_um == pytest.approx( + depletion_width(2e18, 8e18, v_reverse=0.5), rel=1e-12 + ) + total = cfg.xp_um + cfg.xn_um + assert total == pytest.approx(cfg.w_um, rel=1e-12) + # Asymmetric split: more depletion on the lighter-doped side. + assert cfg.xp_um > cfg.xn_um + + def test_dict_construction(self): + cfg = PNJunctionConfig.model_validate({"na_cm3": 1e19, "nd_cm3": 1e19}) + assert cfg.na_cm3 == 1e19 + + def test_linear_requires_grade_const(self): + with pytest.raises(ValidationError, match="grade_const"): + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, grading="linear") + + def test_rejects_beyond_flatband(self): + vbi = built_in_voltage(1e18, 1e18) + with pytest.raises(ValidationError): + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=-vbi - 0.05) + + def test_rejects_bad_concentrations(self): + with pytest.raises(ValidationError): + PNJunctionConfig(na_cm3=0.0, nd_cm3=1e18) + + def test_to_metadata_keys(self): + meta = PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18).to_metadata() + for key in ( + "na_cm3", + "nd_cm3", + "v_bi", + "w_um", + "xp_um", + "xn_um", + "c_per_area_f_m2", + "grading", + ): + assert key in meta + + +# --------------------------------------------------------------------------- +# Part 2: PN-junction profile geometry and mode selection. +# --------------------------------------------------------------------------- + + +CY = -20.0 +RIB_WIDTH = 0.4 +LENGTH = 10.0 + +P_REGION = ("p_rib", (21, 0), 1.6e3) +N_REGION = ("n_rib", (20, 0), 1.6e3) +JUNCTION_REGION = ("junction", (22, 0)) + + +def _thin_junction() -> PNJunctionConfig: + """Na=Nd=1e19 cm^-3 at zero bias -> W ~ 17 nm < threshold.""" + return PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) + + +def _wide_junction() -> PNJunctionConfig: + """Light doping + reverse bias -> W ~ 71 nm > threshold (40 nm).""" + return PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=1.0) + + +def _build(junction, **kwargs): + comp = gf.Component() + kwargs.setdefault("p_region", P_REGION) + kwargs.setdefault("n_region", N_REGION) + kwargs.setdefault("zmin", 0.0) + kwargs.setdefault("zmax", 0.22) + result = make_pn_junction_profile( + comp, + length=LENGTH, + center_y=CY, + rib_width=RIB_WIDTH, + junction=junction, + **kwargs, + ) + return comp, result + + +def _section_rects(comp, result): + """Extract the x=0 plane section from a profile-built component.""" + stack = LayerStack(pdk_name="test") + stack.layers.update(result["layer_specs"]) + for name, mat in result["materials"].items(): + stack.materials[name] = mat.to_dict() + rects = extract_plane_section(comp.copy(), stack, axis="x", value=0.0) + # axis="x" always yields YZ rectangles; narrow the union for attribute access. + return sorted(cast("list[RectYZ2D]", rects), key=lambda r: r.y0) + + +class TestAutoModeSelection: + def test_thin_junction_selects_capacitance(self): + _comp, res = _build(_thin_junction()) + assert res["junction"]["mode"] == "capacitance" + assert "threshold" in res["junction"]["selection_reason"] + + def test_wide_junction_selects_high_res(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + assert res["junction"]["mode"] == "high_res" + + def test_auto_logs_selection_reason(self, caplog): + with caplog.at_level(logging.INFO, logger="gsim.common.stack.pn_junction"): + _comp, _res = _build(_thin_junction()) + assert any("capacitance" in rec.message for rec in caplog.records) + + def test_forced_mode_overrides_auto(self): + _comp, res = _build( + _thin_junction(), mode="high_res", junction_region=JUNCTION_REGION + ) + assert res["junction"]["mode"] == "high_res" + assert "forced" in res["junction"]["selection_reason"] + _comp, res = _build(_wide_junction(), mode="capacitance") + assert res["junction"]["mode"] == "capacitance" + + +class TestCapacitanceModeGeometry: + def test_no_junction_polygon_or_spec(self): + comp, res = _build(_thin_junction()) + assert "junction" not in res["layer_specs"] + assert "junction" not in res["materials"] + # No polygon may exist on the junction GDS layer. + polys = comp.get_polygons(layers=(JUNCTION_REGION[1],)) + assert not any(v for v in polys.values()) + + def test_p_n_adjacent_halves(self): + comp, res = _build(_thin_junction()) + rects = _section_rects(comp, res) + names = [r.layer_name for r in rects] + assert set(names) == {"p_rib", "n_rib"} + by_name = {r.layer_name: r for r in rects} + assert by_name["p_rib"].y0 == pytest.approx(CY) + assert by_name["n_rib"].y1 == pytest.approx(CY) + + def test_junction_metadata_present(self): + junc = _thin_junction() + _comp, res = _build(junc) + meta = res["junction"] + assert meta["w_um"] == pytest.approx(junc.w_um) + assert meta["c_f"] == pytest.approx(junc.capacitance(LENGTH, 0.22)) + assert meta["xp_um"] + meta["xn_um"] == pytest.approx(meta["w_um"]) + + +class TestHighResModeGeometry: + def test_three_contiguous_regions(self): + comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + rects = _section_rects(comp, res) + names = [r.layer_name for r in rects] + assert names == ["n_rib", "junction", "p_rib"] + + n_r, j_r, p_r = rects + # Contiguity with no gaps or overlaps. + assert n_r.y1 == pytest.approx(j_r.y0) + assert j_r.y1 == pytest.approx(p_r.y0) + + junc = _wide_junction() + # Depletion strip spans [cy - xn, cy + xp] (within layout DBU rounding). + assert j_r.y0 == pytest.approx(CY - junc.xn_um, abs=2e-3) + assert j_r.y1 == pytest.approx(CY + junc.xp_um, abs=2e-3) + assert (j_r.y1 - j_r.y0) == pytest.approx(junc.w_um, abs=4e-3) + # Flanks fill the rest of the rib. + assert (p_r.y1 - p_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xp_um, abs=4e-3) + assert (n_r.y1 - n_r.y0) == pytest.approx(RIB_WIDTH / 2 - junc.xn_um, abs=4e-3) + # Full rib span is covered exactly once. + assert p_r.y1 - n_r.y0 == pytest.approx(RIB_WIDTH) + + def test_material_models(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + # Doped regions carry Drude conductivity. + for name in ("p_rib", "n_rib"): + mat = res["materials"][name] + assert mat.conductivity == pytest.approx(1.6e3) + assert mat.permittivity == pytest.approx(11.9) + # Junction strip: depleted silicon -> pure real permittivity, no carriers. + jmat = res["materials"]["junction"] + assert jmat.permittivity == pytest.approx(11.9) + assert jmat.conductivity is None + assert jmat.dispersion_models == [] + + def test_high_res_requires_junction_region(self): + with pytest.raises(ValueError, match="junction_region"): + _build(_wide_junction()) + + def test_layer_specs_reference_materials(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + for name in ("p_rib", "n_rib", "junction"): + spec = res["layer_specs"][name] + assert spec.material == name + assert spec.zmin == 0.0 + assert spec.zmax == 0.22 + + +class TestValidation: + def test_depletion_wider_than_rib_rejected(self): + big = PNJunctionConfig(na_cm3=1e16, nd_cm3=1e16, v_reverse=5.0) + if big.w_um <= RIB_WIDTH: + pytest.skip("picked parameters do not exceed the rib width") + with pytest.raises(ValueError, match="fit"): + _build(big) + + def test_invalid_zmax_rejected(self): + with pytest.raises(ValueError): + _build(_thin_junction(), zmax=-1.0) + + def test_accepts_dict_junction_config(self): + _comp, res = _build( + {"na_cm3": 1e18, "nd_cm3": 1e18, "v_reverse": 1.0}, + junction_region=JUNCTION_REGION, + ) + assert res["junction"]["w_um"] == pytest.approx(_wide_junction().w_um) + assert res["junction"]["mode"] == "high_res" + + +# --------------------------------------------------------------------------- +# Part 3: Palace capacitance vs high-res representation. +# --------------------------------------------------------------------------- + + +F_RF = 50e9 + + +def _build_device(junction: PNJunctionConfig, **profile_kwargs): + """Build the rib+slab+doping device and return (comp, stack).""" + gf.gpdk.PDK.activate() + comp = gf.Component() + wg = comp << gf.c.rectangle((10.0, 0.4), centered=True, layer=(1, 0)) + wg.y = -20.0 + slab = comp << gf.c.rectangle((10.0, 100.0), centered=True, layer=(3, 0)) + slab.y = -5.0 + + pn = make_pn_junction_profile( + comp, + length=10.0, + center_y=-20.0, + rib_width=0.4, + junction=junction, + p_region=("p_rib", (21, 0), 1.6e3), + n_region=("n_rib", (20, 0), 1.6e3), + junction_region=("junction", (22, 0)), + zmin=0.0, + zmax=0.22, + **profile_kwargs, + ) + stack, _section = build_doped_cross_section( + comp, + axis="x", + value=0.0, + substrate_thickness=2.0, + doping=pn, + verbose=False, + ) + return comp, stack, pn + + +def _make_sim(junction: PNJunctionConfig, tmp_path: Path, apply_capacitance: bool): + comp, stack, pn = _build_device(junction) + sim = BoundaryModeSim() + sim.set_output_dir(str(tmp_path / "palace-sim-pn")) + sim.set_stack(stack) + sim.set_airbox(margin_x=3.0, margin_y=3.0, z_above=2.0, z_below=2.0) + sim.set_geometry(comp) + sim.set_cross_section("x=0") + sim.set_boundary_mode(freq=F_RF, num_modes=1, save=0) + sim.mesh(preset="coarse", refined_mesh_size=0.05, max_mesh_size=40.0) + if apply_capacitance: + applied = sim.set_pn_junction( + junction, + layer_p="p_rib", + layer_n="n_rib", + length_um=10.0, + height_um=0.22, + ) + assert applied == pytest.approx(junction.capacitance(10.0, 0.22)) + sim.write_config() + config_path = Path(sim.output_dir) / "config.json" + return sim, json.loads(config_path.read_text()), pn + + +@pytest.fixture(scope="module") +def cap_mode(tmp_path_factory): + """Thin depletion: auto-selected capacitance mode with lumped C.""" + return _make_sim(_thin_junction(), tmp_path_factory.mktemp("cap"), True) + + +@pytest.fixture(scope="module") +def hires_mode(tmp_path_factory): + """Wide depletion: auto-selected high-res mode, no lumped C.""" + return _make_sim(_wide_junction(), tmp_path_factory.mktemp("hires"), False) + + +class TestCapacitanceMode: + def test_no_junction_domain_on_mesh(self, cap_mode): + sim, _config, _pn = cap_mode + groups = sim._last_mesh_result.groups + assert "junction" not in groups["volumes"] + + def test_impedance_boundary_in_config(self, cap_mode): + _sim, config, _pn = cap_mode + impedance = config.get("Boundaries", {}).get("Impedance", []) + assert len(impedance) == 1 + assert "Cs" in impedance[0] + assert impedance[0]["Cs"] > 0 + + def test_cs_value_matches_computed_capacitance(self, cap_mode): + _sim, config, pn = cap_mode + # Interface p_rib|n_rib is the vertical rib edge; its curve length is + # the 0.22 um rib height, so Cs = C / 0.22um. + expected_cs = pn["junction"]["c_f"] / (0.22 * 1e-6) + cs = config["Boundaries"]["Impedance"][0]["Cs"] + assert cs == pytest.approx(expected_cs, rel=1e-9) + + def test_doped_domains_present(self, cap_mode): + sim, _config, _pn = cap_mode + groups = sim._last_mesh_result.groups + assert {"p_rib", "n_rib"} <= set(groups["volumes"]) + + +class TestHighResMode: + def test_junction_dielectric_domain_on_mesh(self, hires_mode): + sim, _config, _pn = hires_mode + groups = sim._last_mesh_result.groups + assert "junction" in groups["volumes"] + assert groups["volumes"]["junction"].get("is_shaped_dielectric") is True + + def test_no_impedance_boundary(self, hires_mode): + _sim, config, _pn = hires_mode + assert not config.get("Boundaries", {}).get("Impedance") + + def test_junction_material_is_pure_dielectric(self, hires_mode): + sim, config, _pn = hires_mode + groups = sim._last_mesh_result.groups + junc_attr = groups["volumes"]["junction"]["phys_group"] + materials = config["Domains"]["Materials"] + entries = [m for m in materials if junc_attr in m.get("Attributes", [])] + assert len(entries) == 1, f"Expected one material for attr {junc_attr}" + entry = entries[0] + assert abs(float(entry["Permittivity"]) - 11.9) < 1e-6 + assert not entry.get("Conductivity"), ( + "Depleted silicon must have zero conductivity" + ) + + def test_p_n_junction_strip_contiguous(self, hires_mode): + """All three regions survive as separate domains.""" + sim, _config, _pn = hires_mode + volumes = sim._last_mesh_result.groups["volumes"] + assert {"p_rib", "n_rib", "junction"} <= set(volumes) + + +# --------------------------------------------------------------------------- +# Part 4: 1D free-carrier plasma dispersion (complex optical permittivity). +# --------------------------------------------------------------------------- + +LAMBDA_1550_UM = 1.55 +EPS_BG_SI_1550 = 12.0946 # Si Sellmeier at 1.55 um (reference value) + + +class TestCarrierProfile1D: + def test_bulk_and_depleted_values(self): + na = nd = 1e18 + junc = PNJunctionConfig(na_cm3=na, nd_cm3=nd) + y = np.array([-0.3, -junc.xn_um / 2, 0.0, junc.xp_um / 2, 0.3]) + n, p = carrier_profile_1d( + y, + center_um=0.0, + xp_um=junc.xp_um, + xn_um=junc.xn_um, + na_cm3=na, + nd_cm3=nd, + ni_cm3=junc.ni_cm3, + ) + assert n[0] == pytest.approx(nd) + assert p[0] == pytest.approx(junc.ni_cm3**2 / nd) + assert p[-1] == pytest.approx(na) + assert n[-1] == pytest.approx(junc.ni_cm3**2 / na) + assert n[1] == pytest.approx(junc.ni_cm3) + assert p[2] == pytest.approx(junc.ni_cm3) + + def test_mass_action_holds_everywhere(self): + junc = PNJunctionConfig(na_cm3=3e17, nd_cm3=2e18, v_reverse=1.0) + y = np.linspace(-0.3, 0.3, 601) + n, p = carrier_profile_1d( + y, + center_um=0.0, + xp_um=junc.xp_um, + xn_um=junc.xn_um, + na_cm3=3e17, + nd_cm3=2e18, + ni_cm3=junc.ni_cm3, + ) + assert n * p / junc.ni_cm3**2 == pytest.approx(np.ones_like(y)) + + def test_asymmetric_split_respected(self): + # NA >> ND: depletion lies almost entirely on the N side. + junc = PNJunctionConfig(na_cm3=1e20, nd_cm3=1e17) + assert junc.xn_um > 0.9 * junc.w_um + n, p = carrier_profile_1d( + np.array([-junc.w_um, junc.w_um]), + center_um=0.0, + xp_um=junc.xp_um, + xn_um=junc.xn_um, + na_cm3=1e20, + nd_cm3=1e17, + ) + assert n[0] == pytest.approx(1e17) + assert p[1] == pytest.approx(1e20) + + def test_rejects_bad_inputs(self): + with pytest.raises(ValueError): + carrier_profile_1d( + [0.0], center_um=0.0, xp_um=0.1, xn_um=0.1, na_cm3=0.0, nd_cm3=1e18 + ) + with pytest.raises(ValueError): + carrier_profile_1d( + [0.0], center_um=0.0, xp_um=-0.1, xn_um=0.1, na_cm3=1e18, nd_cm3=1e18 + ) + + +class TestDrudeOptics: + def test_relaxation_times_in_relaxation_regime(self): + tau_e, tau_h = drude_relaxation_times() + assert tau_e > tau_h > 0 + omega = 2 * math.pi * 299792458 / (LAMBDA_1550_UM * 1e-6) + assert omega * tau_e > 100 + assert omega * tau_h > 100 + with pytest.raises(ValueError): + drude_relaxation_times(mu_n_cm2_vs=0.0) + + def test_intrinsic_returns_background(self): + eps = epsilon_eff_relative( + NI_SI_300K_CM3, + NI_SI_300K_CM3, + wavelength_um=LAMBDA_1550_UM, + eps_bg_rel=EPS_BG_SI_1550, + ) + assert float(np.real(eps)) == pytest.approx(EPS_BG_SI_1550, rel=1e-6) + assert abs(float(np.imag(eps))) < 1e-8 + + def test_electron_shift_matches_soref_scale(self): + # Soref-Bennett at 1550 nm: dn_e = -8.8e-22 * dN (N in cm^-3). + eps = epsilon_eff_relative( + 2e16, 0.0, wavelength_um=LAMBDA_1550_UM, eps_bg_rel=EPS_BG_SI_1550 + ) + n, _k = refractive_index(eps) + dn = float(n) - math.sqrt(EPS_BG_SI_1550) + assert dn == pytest.approx(-8.8e-22 * 2e16, rel=0.5) + + def test_heavy_doping_shift_and_loss(self): + eps = epsilon_eff_relative( + 1e18, 1e18, wavelength_um=LAMBDA_1550_UM, eps_bg_rel=EPS_BG_SI_1550 + ) + n, k = refractive_index(eps) + dn = float(n) - math.sqrt(EPS_BG_SI_1550) + assert -3e-3 < dn < -5e-4 + assert 0.0 < float(k) < 1e-4 + + def test_optical_params_mapping(self): + eps = epsilon_eff_relative( + 1e18, 1e18, wavelength_um=LAMBDA_1550_UM, eps_bg_rel=EPS_BG_SI_1550 + ) + prime, sigma = optical_params(eps, LAMBDA_1550_UM) + omega = 2 * math.pi * 299792458 / (LAMBDA_1550_UM * 1e-6) + + assert isinstance(prime, float) + assert isinstance(sigma, float) + assert prime == pytest.approx(float(np.real(eps)), rel=1e-12) + assert sigma == pytest.approx(omega * EPS0 * float(np.imag(eps)), rel=1e-12) + assert sigma > 0 + # Array input stays array. + ep_arr, _sg_arr = optical_params([eps, eps], LAMBDA_1550_UM) + assert isinstance(ep_arr, np.ndarray) + assert ep_arr.shape == (2,) + + def test_junction_profile_depletion_dip(self): + junc = PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18) + y = np.linspace(-0.2, 0.2, 401) + prof = junction_epsilon_profile(y, junc, wavelength_um=LAMBDA_1550_UM) + assert set(prof) >= { + "y_um", + "n_cm3", + "p_cm3", + "eps_rel", + "eps_prime", + "sigma_Sm", + "n_index", + "k_index", + "junction", + } + # Quasi-neutral wings carry the plasma shift; the depletion dip does not. + assert prof["eps_prime"][0] < prof["eps_bg_rel"] + mid = prof["eps_prime"][200] + assert mid == pytest.approx(prof["eps_bg_rel"], rel=1e-6) + assert prof["sigma_Sm"][200] < 1e-6 < prof["sigma_Sm"][0] + + def test_reverse_bias_widens_depleted_dip(self): + y = np.linspace(-0.2, 0.2, 401) + wide = junction_epsilon_profile( + y, + {"na_cm3": 1e17, "nd_cm3": 1e17, "v_reverse": 3.0}, + wavelength_um=LAMBDA_1550_UM, + ) + narrow = junction_epsilon_profile( + y, + {"na_cm3": 1e17, "nd_cm3": 1e17}, + wavelength_um=LAMBDA_1550_UM, + ) + assert wide["junction"]["w_um"] > 2 * narrow["junction"]["w_um"] + depleted = np.asarray(wide["sigma_Sm"]) < 1e-6 + assert depleted.sum() > (np.asarray(narrow["sigma_Sm"]) < 1e-6).sum() + + +# --------------------------------------------------------------------------- +# Part 5: segmented-junction strips and their Palace representation. +# --------------------------------------------------------------------------- + +SEG_CY = -20.0 +SEG_RIB = 0.4 +SEG_LENGTH = 10.0 +SEG_JUNCTION = {"na_cm3": 1e18, "nd_cm3": 1e18} + + +def _build_segmented(n_p=8, n_n=8, **kwargs): + comp = gf.Component() + result = make_segmented_junction_profile( + comp, + length=SEG_LENGTH, + center_y=SEG_CY, + rib_width=SEG_RIB, + junction=SEG_JUNCTION, + n_p=n_p, + n_n=n_n, + zmin=0.0, + zmax=0.22, + **kwargs, + ) + return comp, result + + +class TestSegmentedProfileGeometry: + def test_sixteen_strips_cover_rib(self): + _comp, res = _build_segmented() + assert len(res["layer_specs"]) == 16 + assert len(res["materials"]) == 16 + rects = sorted((s["y0_um"], s["y1_um"]) for s in res["segments"].values()) + assert rects[0][0] == pytest.approx(SEG_CY - SEG_RIB / 2) + assert rects[-1][1] == pytest.approx(SEG_CY + SEG_RIB / 2) + for (_y0, y1), (y0_next, _y1) in pairwise(rects): + assert y1 == pytest.approx(y0_next) + total = sum(y1 - y0 for y0, y1 in rects) + assert total == pytest.approx(SEG_RIB) + + def test_numbering_runs_junction_outward(self): + _comp, res = _build_segmented() + assert res["segments"]["p_1"]["y0_um"] == pytest.approx(SEG_CY) + assert res["segments"]["n_1"]["y1_um"] == pytest.approx(SEG_CY) + assert res["segments"]["p_8"]["y1_um"] == pytest.approx(SEG_CY + SEG_RIB / 2) + assert res["segments"]["n_8"]["y0_um"] == pytest.approx(SEG_CY - SEG_RIB / 2) + + def test_junction_strips_depleted_outer_strips_bulk(self): + _comp, res = _build_segmented() + bg = res["junction"]["eps_bg_rel"] + for name in ("p_1", "n_1"): + seg = res["segments"][name] + assert seg["n_cm3"] == pytest.approx(NI_SI_300K_CM3, rel=0.01) + assert seg["eps_prime"] == pytest.approx(bg, rel=1e-9) + assert res["materials"][name].conductivity is None + for name in ("p_8", "n_8"): + seg = res["segments"][name] + assert seg["eps_prime"] < res["segments"]["p_1"]["eps_prime"] + assert res["materials"][name].conductivity is not None + assert res["materials"][name].conductivity > 0 + + def test_materials_match_center_sampling(self): + _comp, res = _build_segmented() + cfg = PNJunctionConfig.model_validate(SEG_JUNCTION) + for name, seg in res["segments"].items(): + prof = junction_epsilon_profile( + [seg["yc_um"]], + cfg, + center_um=SEG_CY, + wavelength_um=res["junction"]["wavelength_um"], + ) + assert res["materials"][name].permittivity == pytest.approx( + float(prof["eps_prime"][0]), rel=1e-12 + ) + + def test_gds_layers_unique(self): + _comp, res = _build_segmented() + layers = [s["gds_layer"] for s in res["segments"].values()] + assert len(set(layers)) == 16 + + def test_section_extraction_yields_strips(self): + comp, res = _build_segmented() + stack = LayerStack(pdk_name="test") + stack.layers.update(res["layer_specs"]) + for name, mat in res["materials"].items(): + stack.materials[name] = mat.to_dict() + rects = extract_plane_section(comp.copy(), stack, axis="x", value=0.0) + names = sorted(r.layer_name for r in cast("list[RectYZ2D]", rects)) + assert names == sorted(res["segments"]) + + def test_rejects_bad_counts(self): + with pytest.raises(ValueError, match="positive integers"): + _build_segmented(n_p=0) + + +class TestSegmentedOpticalConfig: + F_OPT = 193.4e12 + + def _optical_sim(self, tmp_path, res, comp): + gf.gpdk.PDK.activate() + device_layers = { + "core": ((1, 0), 0.0, 0.22), + "slab": ((3, 0), 0.0, 0.09), + } + device_materials = {} + extra_materials = {} + for name, spec in res["layer_specs"].items(): + device_layers[name] = (tuple(spec.gds_layer), 0.0, 0.22) + device_materials[name] = name + extra_materials[name] = res["materials"][name] + stack, _section = build_optical_cross_section( + comp, + axis="x", + value=0.0, + device_layers=device_layers, + device_materials=device_materials, + extra_materials=extra_materials, + substrate_thickness=2.0, + cladding_top=2.0, + verbose=False, + ) + sim = BoundaryModeSim() + sim.set_output_dir(str(tmp_path / "palace-sim-seg-opt")) + sim.set_stack(stack) + sim.set_airbox( + material="sio2", margin_x=1.0, margin_y=1.0, z_above=1.0, z_below=1.0 + ) + sim.set_geometry(comp) + sim.set_cross_section("x=0") + sim.set_boundary_mode(freq=self.F_OPT, num_modes=1, save=0) + sim.mesh(preset="coarse", refined_mesh_size=0.02, max_mesh_size=1.0) + sim.write_config() + config = json.loads((Path(sim.output_dir) / "config.json").read_text()) + return sim, config + + def test_strip_domains_carry_permittivity_and_conductivity(self, tmp_path): + comp, res = _build_segmented() + sim, config = self._optical_sim(tmp_path, res, comp) + volumes = sim._last_mesh_result.groups["volumes"] + assert {f"p_{i}" for i in range(1, 9)} <= set(volumes) + assert {f"n_{i}" for i in range(1, 9)} <= set(volumes) + materials = config["Domains"]["Materials"] + bg = res["junction"]["eps_bg_rel"] + for name, seg in res["segments"].items(): + attr = volumes[name]["phys_group"] + entries = [m for m in materials if attr in m.get("Attributes", [])] + assert len(entries) == 1, f"Expected one material for {name}" + entry = entries[0] + assert float(entry["Permittivity"]) == pytest.approx( + seg["eps_prime"], rel=1e-9 + ) + if seg["sigma_Sm"] >= 1e-6: + assert float(entry["Conductivity"]) == pytest.approx( + seg["sigma_Sm"], rel=1e-9 + ) + assert float(entry["Permittivity"]) < bg + else: + assert not entry.get("Conductivity") + assert float(entry["Permittivity"]) == pytest.approx(bg, rel=1e-9) diff --git a/tests/palace/test_pn_junction_modes.py b/tests/palace/test_pn_junction_modes.py deleted file mode 100644 index 6c691946..00000000 --- a/tests/palace/test_pn_junction_modes.py +++ /dev/null @@ -1,159 +0,0 @@ -"""End-to-end tests: PN-junction capacitance vs high-res mesh representation. - -Capacitance mode must produce a Palace ``Boundaries.Impedance`` entry with -``Cs = C / interface_length`` and no junction domain. High-res mode must -produce a ``junction`` dielectric domain (pure real permittivity) and no -Impedance boundary. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import gdsfactory as gf -import pytest - -from gsim.common.cross_section import build_doped_cross_section -from gsim.common.stack.doping import make_pn_junction_profile -from gsim.common.stack.junction import PNJunctionConfig -from gsim.palace import BoundaryModeSim - -F_RF = 50e9 - - -def _thin_junction() -> PNJunctionConfig: - """W ~ 17 nm -> below the auto threshold -> capacitance mode.""" - return PNJunctionConfig(na_cm3=1e19, nd_cm3=1e19) - - -def _wide_junction() -> PNJunctionConfig: - """W ~ 71 nm -> above the auto threshold -> high-res mode.""" - return PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18, v_reverse=1.0) - - -def _build_device(junction: PNJunctionConfig, **profile_kwargs): - """Build the rib+slab+doping device and return (comp, stack).""" - gf.gpdk.PDK.activate() - comp = gf.Component() - wg = comp << gf.c.rectangle((10.0, 0.4), centered=True, layer=(1, 0)) - wg.y = -20.0 - slab = comp << gf.c.rectangle((10.0, 100.0), centered=True, layer=(3, 0)) - slab.y = -5.0 - - pn = make_pn_junction_profile( - comp, - length=10.0, - center_y=-20.0, - rib_width=0.4, - junction=junction, - p_region=("p_rib", (21, 0), 1.6e3), - n_region=("n_rib", (20, 0), 1.6e3), - junction_region=("junction", (22, 0)), - zmin=0.0, - zmax=0.22, - **profile_kwargs, - ) - stack, _section = build_doped_cross_section( - comp, - axis="x", - value=0.0, - substrate_thickness=2.0, - doping=pn, - verbose=False, - ) - return comp, stack, pn - - -def _make_sim(junction: PNJunctionConfig, tmp_path: Path, apply_capacitance: bool): - comp, stack, pn = _build_device(junction) - sim = BoundaryModeSim() - sim.set_output_dir(str(tmp_path / "palace-sim-pn")) - sim.set_stack(stack) - sim.set_airbox(margin_x=3.0, margin_y=3.0, z_above=2.0, z_below=2.0) - sim.set_geometry(comp) - sim.set_cross_section("x=0") - sim.set_boundary_mode(freq=F_RF, num_modes=1, save=0) - sim.mesh(preset="coarse", refined_mesh_size=0.05, max_mesh_size=40.0) - if apply_capacitance: - applied = sim.set_pn_junction( - junction, - layer_p="p_rib", - layer_n="n_rib", - length_um=10.0, - height_um=0.22, - ) - assert applied == pytest.approx(junction.capacitance(10.0, 0.22)) - sim.write_config() - config_path = Path(sim.output_dir) / "config.json" - return sim, json.loads(config_path.read_text()), pn - - -@pytest.fixture(scope="module") -def cap_mode(tmp_path_factory): - """Thin depletion: auto-selected capacitance mode with lumped C.""" - return _make_sim(_thin_junction(), tmp_path_factory.mktemp("cap"), True) - - -@pytest.fixture(scope="module") -def hires_mode(tmp_path_factory): - """Wide depletion: auto-selected high-res mode, no lumped C.""" - return _make_sim(_wide_junction(), tmp_path_factory.mktemp("hires"), False) - - -class TestCapacitanceMode: - def test_no_junction_domain_on_mesh(self, cap_mode): - sim, _config, _pn = cap_mode - groups = sim._last_mesh_result.groups - assert "junction" not in groups["volumes"] - - def test_impedance_boundary_in_config(self, cap_mode): - _sim, config, _pn = cap_mode - impedance = config.get("Boundaries", {}).get("Impedance", []) - assert len(impedance) == 1 - assert "Cs" in impedance[0] - assert impedance[0]["Cs"] > 0 - - def test_cs_value_matches_computed_capacitance(self, cap_mode): - _sim, config, pn = cap_mode - # Interface p_rib|n_rib is the vertical rib edge; its curve length is - # the 0.22 um rib height, so Cs = C / 0.22um. - expected_cs = pn["junction"]["c_f"] / (0.22 * 1e-6) - cs = config["Boundaries"]["Impedance"][0]["Cs"] - assert cs == pytest.approx(expected_cs, rel=1e-9) - - def test_doped_domains_present(self, cap_mode): - sim, _config, _pn = cap_mode - groups = sim._last_mesh_result.groups - assert {"p_rib", "n_rib"} <= set(groups["volumes"]) - - -class TestHighResMode: - def test_junction_dielectric_domain_on_mesh(self, hires_mode): - sim, _config, _pn = hires_mode - groups = sim._last_mesh_result.groups - assert "junction" in groups["volumes"] - assert groups["volumes"]["junction"].get("is_shaped_dielectric") is True - - def test_no_impedance_boundary(self, hires_mode): - _sim, config, _pn = hires_mode - assert not config.get("Boundaries", {}).get("Impedance") - - def test_junction_material_is_pure_dielectric(self, hires_mode): - sim, config, _pn = hires_mode - groups = sim._last_mesh_result.groups - junc_attr = groups["volumes"]["junction"]["phys_group"] - materials = config["Domains"]["Materials"] - entries = [m for m in materials if junc_attr in m.get("Attributes", [])] - assert len(entries) == 1, f"Expected one material for attr {junc_attr}" - entry = entries[0] - assert abs(float(entry["Permittivity"]) - 11.9) < 1e-6 - assert not entry.get("Conductivity"), ( - "Depleted silicon must have zero conductivity" - ) - - def test_p_n_junction_strip_contiguous(self, hires_mode): - """All three regions survive as separate domains.""" - sim, _config, _pn = hires_mode - volumes = sim._last_mesh_result.groups["volumes"] - assert {"p_rib", "n_rib", "junction"} <= set(volumes) From 97cf1e8c2e2ed40b40f2f5dbd9d233b86e71c8a1 Mon Sep 17 00:00:00 2001 From: mdmaas Date: Sat, 12 Sep 2026 03:07:25 -0300 Subject: [PATCH 08/14] feat(palace): BoundaryMode voltage/impedance postprocessing ports Add postprocessing-only lumped/CPW ports to BoundaryModeSim. These do not load the 2D eigenproblem; Palace evaluates the port voltage paths after the mode is found and writes mode-V.csv (complex integrated voltage) and mode-Z.csv (characteristic impedance Z_PV/L_PV/C_PV, plus Z_VI/L_VI/C_VI when a CurrentPath is given). - PortConfig/CPWPortConfig gain voltage_path(s)/current_path/nsamples plus center/orientation/width/order; add_port/add_cpw_port thread them through. - Derive cross-section voltage paths from port + stack geometry, or accept explicit 2D/3D paths; emit Boundaries.Postprocessing for boundarymode. - Allow lumped/CPW ports in BoundaryModeSim (wave ports still rejected). - Parse mode-Z.csv/mode-V.csv in PalaceTextResults via characteristic_impedance() and mode_voltage(). - TW-MZM notebook: add gdsfactory rf_in (CPW/GSG on metal1) and junction (lumped, WG at the PN junction) ports, and report the RF line impedance and junction V_bias. --- nbs/palace_2d_twmzm.ipynb | 123 ++++++++++-- src/gsim/palace/base.py | 239 ++++++++++++++++++++++- src/gsim/palace/boundarymode.py | 4 +- src/gsim/palace/mesh/config_generator.py | 5 + src/gsim/palace/models/ports.py | 79 +++++++- src/gsim/palace/results.py | 108 ++++++++++ tests/palace/test_config_dispersion.py | 35 ++++ tests/palace/test_results.py | 63 ++++++ tests/palace/test_sim_classes.py | 76 +++++++ 9 files changed, 713 insertions(+), 19 deletions(-) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index 4e461f1a..bc3f2385 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -305,6 +305,27 @@ " via_g_to_n.x = 0.0\n", " via_g_to_n.y = VIA_G_TO_N_Y\n", "\n", + " # 7. gdsfactory ports (consumed as BoundaryMode voltage paths) -----------\n", + " # Input: CPW/GSG port at the signal centre. The 2D RF postprocessing\n", + " # derives the two gap voltage paths from this port's geometry.\n", + " comp.add_port(\n", + " name=\"rf_in\",\n", + " center=(0.0, 0.0),\n", + " width=SIG_WIDTH,\n", + " orientation=0.0,\n", + " port_type=\"electrical\",\n", + " layer=LAYER.M1,\n", + " )\n", + " # Output: lumped port on the rib waveguide at the PN junction.\n", + " comp.add_port(\n", + " name=\"junction\",\n", + " center=(0.0, RIB_CENTER_Y),\n", + " width=RIB_WIDTH,\n", + " orientation=180.0,\n", + " port_type=\"electrical\",\n", + " layer=LAYER.WG,\n", + " )\n", + "\n", " return comp, doping_result, pn_result\n", "\n", "\n", @@ -703,10 +724,36 @@ "# --- PN junction lumped model -------------------------------------------------" ] }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "## RF postprocessing ports (voltage paths)\n", + "\n", + "For the RF solve we add two **gdsfactory ports** to the model and register\n", + "them for the `BoundaryMode` solve. In a `BoundaryMode` simulation these are\n", + "not lumped loads: they define line-integral paths that Palace evaluates on\n", + "the computed mode without perturbing the 2D eigenproblem.\n", + "\n", + "- **Input (`rf_in`)**: a CPW/GSG port on the signal electrode (`metal1`),\n", + " connecting the top GSG electrodes. The two gap voltage paths are derived\n", + " automatically from the port geometry, `SIG_WIDTH` and `GAP_WIDTH`. Palace\n", + " reports the power-voltage characteristic impedance `Z_PV` (plus `L_PV`,\n", + " `C_PV`) in `mode-Z.csv`.\n", + "- **Output (`junction`)**: a lumped port on the rib waveguide (`WG`) in the\n", + " junction region, with a `P -> N` voltage path. Palace reports the\n", + " integrated mode voltage in `mode-V.csv`; this is the bias `V_bias` fed\n", + " to the optical solve.\n", + "\n", + "The port declaration order fixes the postprocessing indices: `1, 2` are\n", + "the input CPW gaps and `3` is the junction.\n" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -722,6 +769,21 @@ "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", + "# -- BoundaryMode postprocessing ports (voltage paths) -----------------------\n", + "# These are NOT lumped-port loads: in a BoundaryMode (2D eigenmode) solve\n", + "# they only define line-integral paths Palace evaluates after the mode is\n", + "# found, so they do not perturb the eigenproblem. Both refer to gdsfactory\n", + "# ports added to the model above and derive their geometry from them.\n", + "# * Input: the GSG CPW differential port at the signal centre. The two gap\n", + "# voltage paths are derived from the port and s_width/gap_width at the\n", + "# metal mid-plane. Palace writes Z_PV, L_PV and C_PV to mode-Z.csv.\n", + "# * Output: a lumped port across the middle of the PN junction (P -> N).\n", + "# Its voltage is integrated along the rib mid-plane into mode-V.csv; it\n", + "# becomes V_bias for the optical simulation.\n", + "sim.add_cpw_port(\"rf_in\", layer=\"metal1\", s_width=SIG_WIDTH, gap_width=GAP_WIDTH)\n", + "\n", + "sim.add_port(\"junction\", layer=\"p_rib\", nsamples=200)\n", + "\n", "# -- Mesh ---------------------------------------------------------------------\n", "sim.mesh(\n", " preset=RF_MESH[\"preset\"],\n", @@ -741,7 +803,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -756,7 +818,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -788,7 +850,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -800,7 +862,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -830,7 +892,38 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "# --- RF line impedance and junction bias voltage ----------------------------\n", + "# Palace's BoundaryMode postprocessing computes, from the port voltage paths:\n", + "# * mode-Z.csv: characteristic impedance Z_PV = |V|^2 / (2P), with P the\n", + "# time-averaged mode power, plus per-unit-length L_PV / C_PV.\n", + "# * mode-V.csv: complex integrated mode voltage.\n", + "# The intrinsic (medium) impedance eta_eff = eta0 / n_eff comes from the mode\n", + "# index and is reported in `results.modes`.\n", + "if not results.mode_impedance and not results.mode_voltages:\n", + " print(\"No mode-Z.csv / mode-V.csv found - re-run the RF solve.\")\n", + "else:\n", + " for mode_id in sorted(results.modes):\n", + " eta = results.modes[mode_id][\"eta_eff\"]\n", + " print(f\"mode {mode_id}: intrinsic impedance eta_eff = {eta.real:.2f} Ohm\")\n", + "\n", + " print(\"\\nInput CPW line characteristic impedance (mode-Z.csv):\")\n", + " for index in (1, 2):\n", + " z_pv = results.characteristic_impedance(index=index, mode=1, quantity=\"Z_PV\")\n", + " if z_pv is not None:\n", + " print(f\" gap {index}, mode 1: Z_PV = {z_pv:.2f} Ohm\")\n", + "\n", + " V_bias = results.mode_voltage(index=3, mode=1)\n", + " print(f\"\\nJunction integrated voltage (mode 1): V_bias = {V_bias} V\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -848,7 +941,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "29", "metadata": {}, "source": [ "## Optical simulation (1550 nm)\n", @@ -868,7 +961,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "30", "metadata": { "lines_to_next_cell": 2 }, @@ -903,7 +996,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -969,7 +1062,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -984,7 +1077,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -995,7 +1088,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -1013,7 +1106,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -1029,7 +1122,7 @@ }, { "cell_type": "markdown", - "id": "34", + "id": "36", "metadata": {}, "source": [ "## Summary\n", @@ -1095,7 +1188,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "37", "metadata": {}, "outputs": [], "source": [ diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 2ba2b13a..296c1bfa 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -962,7 +962,11 @@ def validate_config(self) -> ValidationResult: else: # Validate port configurations for port in self.ports: - if port.geometry == "inplane" and port.layer is None: + if ( + port.geometry == "inplane" + and port.layer is None + and port.voltage_path is None + ): errors.append(f"Port '{port.name}': inplane ports require 'layer'") if port.geometry == "via" and ( port.from_layer is None or port.to_layer is None @@ -1139,6 +1143,188 @@ def _configure_ports_on_component(self, stack: LayerStack) -> None: # noqa: ARG self._configured_ports = True + # ------------------------------------------------------------------------- + # BoundaryMode postprocessing (2D voltage / impedance paths) + # ------------------------------------------------------------------------- + + @staticmethod + def _project_mode_path( + points: list[list[float]], axis: Literal["x", "y", "z"] + ) -> list[list[float]]: + """Project path points to the 2D cross-section coordinate frame. + + Points of length 2 are assumed to already be in cross-section + coordinates ``(h, v)`` and are passed through. Points of length 3 are + layout coordinates ``(x, y, z)`` and are mapped using *axis*: for an + x-normal plane ``(h, v) = (y, z)``, for a y-normal plane + ``(h, v) = (x, z)``. + """ + projected: list[list[float]] = [] + for point in points: + vals = [float(v) for v in point] + if len(vals) == 2: + projected.append(vals) + elif len(vals) == 3: + x, y, z = vals + if axis == "x": + projected.append([y, z]) + elif axis == "y": + projected.append([x, z]) + else: + projected.append([x, y]) + else: + raise ValueError( + "BoundaryMode path points must have 2 (cross-section) or " + f"3 (layout) coordinates, got {len(vals)}." + ) + return projected + + def _try_find_gf_port(self, port_name: str): + """Find a gdsfactory port by name, or return ``None`` if absent.""" + component = self.geometry.component if self.geometry else None + if component is None: + return None + for port in component.ports: + if port.name == port_name: + return port + return None + + @staticmethod + def _resolve_path_geometry( + center: tuple[float, float] | None, + orientation: float, + gf_port, + config_width: float | None = None, + ) -> tuple[tuple[float, float] | None, float, float | None]: + """Resolve (center, orientation, width) from config and/or gf port.""" + width: float | None = config_width + if gf_port is not None: + if center is None: + center = (float(gf_port.center[0]), float(gf_port.center[1])) + if not orientation: + orientation = float(gf_port.orientation or 0.0) + if width is None: + width = float(gf_port.width) + return center, orientation, width + + def _derive_single_port_path( + self, port: PortConfig, stack: LayerStack + ) -> list[list[float]] | None: + """Derive a BoundaryMode voltage path across a single lumped port.""" + import numpy as np + + center, orientation, width = self._resolve_path_geometry( + port.center, + port.orientation, + self._try_find_gf_port(port.name), + config_width=port.width, + ) + if center is None or width is None or port.layer is None: + return None + layer = stack.layers.get(port.layer) + if layer is None: + return None + z = 0.5 * (layer.zmin + layer.zmax) + theta = np.deg2rad(orientation) + transverse = np.array([-np.sin(theta), np.cos(theta)]) + c = np.array(center) + half = width / 2.0 + p0 = c - transverse * half + p1 = c + transverse * half + return [[float(p0[0]), float(p0[1]), z], [float(p1[0]), float(p1[1]), z]] + + def _derive_cpw_gap_paths( + self, cpw: CPWPortConfig, stack: LayerStack + ) -> list[list[list[float]]] | None: + """Derive the two BoundaryMode voltage paths across a CPW's gaps.""" + import numpy as np + + center, orientation, _ = self._resolve_path_geometry( + cpw.center, cpw.orientation, self._try_find_gf_port(cpw.name) + ) + if center is None or not cpw.layer: + return None + layer = stack.layers.get(cpw.layer) + if layer is None: + return None + z = 0.5 * (layer.zmin + layer.zmax) + theta = np.deg2rad(orientation) + transverse = np.array([-np.sin(theta), np.cos(theta)]) + c = np.array(center) + s = cpw.s_width / 2.0 + g = cpw.gap_width + + def _path(sign: float) -> list[list[float]]: + p_in = c + sign * transverse * s + p_out = c + sign * transverse * (s + g) + return [ + [float(p_in[0]), float(p_in[1]), z], + [float(p_out[0]), float(p_out[1]), z], + ] + + return [_path(1.0), _path(-1.0)] + + def _build_boundarymode_postprocessing( + self, stack: LayerStack, cross_section + ) -> dict[str, list[dict[str, object]]]: + """Build Palace ``Boundaries.Postprocessing`` entries from ports. + + These entries are postprocessing-only: they do not load or otherwise + alter the 2D eigenproblem. Each voltage path produces both an + ``Impedance`` entry (characteristic impedance, ``mode-Z.csv``) and a + ``Voltage`` entry (mode voltage, ``mode-V.csv``). + """ + axis: Literal["x", "y", "z"] = ( + getattr(cross_section, "axis", "x") if cross_section is not None else "x" + ) + impedance_entries: list[dict[str, object]] = [] + voltage_entries: list[dict[str, object]] = [] + index = 0 + + def _append(path: list[list[float]], nsamples: int, current_path) -> None: + nonlocal index + index += 1 + projected = self._project_mode_path(path, axis) + entry: dict[str, object] = { + "Index": index, + "VoltagePath": projected, + "NSamples": nsamples, + } + if current_path is not None: + entry["CurrentPath"] = self._project_mode_path(current_path, axis) + impedance_entries.append(entry) + voltage_entries.append( + {"Index": index, "VoltagePath": projected, "NSamples": nsamples} + ) + + ordered: list[tuple[int, PortConfig | CPWPortConfig]] = [ + (port.order, port) for port in self.ports + ] + [(cpw.order, cpw) for cpw in self.cpw_ports] + + for _order, config in sorted(ordered, key=lambda item: item[0]): + if isinstance(config, PortConfig): + if config.voltage_path is not None: + path = config.voltage_path + else: + path = self._derive_single_port_path(config, stack) + if path is not None: + _append(path, config.nsamples, config.current_path) + else: + if config.voltage_paths: + paths = list(config.voltage_paths) + else: + derived = self._derive_cpw_gap_paths(config, stack) + paths = derived or [] + for path in paths: + _append(path, config.nsamples, config.current_path) + + result: dict[str, list[dict[str, object]]] = {} + if impedance_entries: + result["Impedance"] = impedance_entries + if voltage_entries: + result["Voltage"] = voltage_entries + return result + def _generate_mesh_internal( self, output_dir: Path, @@ -1706,6 +1892,15 @@ def write_config( if self._impedance_boundaries: hints["_impedance_boundaries"] = self._impedance_boundaries + # BoundaryMode voltage/impedance postprocessing from the configured + # lumped/CPW ports. These paths do not affect the 2D eigenproblem. + if self.simulation_type == "boundarymode": + postprocessing = self._build_boundarymode_postprocessing( + stack, getattr(self, "cross_section", None) + ) + if postprocessing: + hints["_mode_postprocessing"] = postprocessing + config_path = gen_write_config( mesh_result=self._last_mesh_result, stack=stack, @@ -2392,6 +2587,12 @@ def add_port( capacitance: float | None = None, excited: bool = True, geometry: Literal["inplane", "via"] = "inplane", + voltage_path: list[list[float]] | None = None, + current_path: list[list[float]] | None = None, + nsamples: int = 100, + center: tuple[float, float] | None = None, + orientation: float = 0.0, + width: float | None = None, ) -> None: """Add a single-element lumped port. @@ -2409,6 +2610,15 @@ def add_port( capacitance: Shunt capacitance (F) excited: Whether this port is excited geometry: Port geometry type ("inplane" or "via") + voltage_path: For BoundaryMode only - open signal->ground path (um) + used to post-process the mode voltage (mode-V.csv). Points may + be 2D cross-section coordinates or 3D layout coordinates. + current_path: For BoundaryMode only - closed-loop path (um) for the + current line integral (impedance postprocessing). + nsamples: Line-integral sample count for BoundaryMode postprocessing. + center: Explicit (x, y) center (um) for auto-deriving a BoundaryMode + voltage path when ``voltage_path`` is not given. + orientation: Port orientation in degrees (0 = +x). Example: >>> sim.add_port("o1", layer="topmetal2", length=5.0) @@ -2433,6 +2643,13 @@ def add_port( capacitance=capacitance, excited=excited, geometry=geometry, + voltage_path=voltage_path, + current_path=current_path, + nsamples=nsamples, + center=center, + orientation=orientation, + width=width, + order=len(self.ports) + len(self.cpw_ports), ) ) @@ -2447,6 +2664,11 @@ def add_cpw_port( offset: float | None = None, impedance: float = 50.0, excited: bool = True, + voltage_paths: list[list[list[float]]] | None = None, + current_path: list[list[float]] | None = None, + nsamples: int = 100, + center: tuple[float, float] | None = None, + orientation: float = 0.0, ) -> None: """Add a coplanar waveguide (CPW) port. @@ -2467,6 +2689,15 @@ def add_cpw_port( Defaults to length/2 (port flush with conductor edge). impedance: Port impedance (Ohms) excited: Whether this port is excited + voltage_paths: For BoundaryMode only - explicit signal->ground paths + (um), one per gap. When omitted, the two gap paths are + auto-derived from ``center``/the component port and the layer. + current_path: For BoundaryMode only - closed-loop path (um) for the + current line integral. + nsamples: Line-integral sample count for BoundaryMode postprocessing. + center: Explicit (x, y) signal-center (um) for auto-deriving the + BoundaryMode gap paths. + orientation: Port orientation in degrees (0 = +x). Example: >>> sim.add_cpw_port( @@ -2486,6 +2717,12 @@ def add_cpw_port( offset=offset, impedance=impedance, excited=excited, + voltage_paths=voltage_paths, + current_path=current_path, + nsamples=nsamples, + center=center, + orientation=orientation, + order=len(self.ports) + len(self.cpw_ports), ) ) diff --git a/src/gsim/palace/boundarymode.py b/src/gsim/palace/boundarymode.py index 4b9f1b6d..ad34f1d8 100644 --- a/src/gsim/palace/boundarymode.py +++ b/src/gsim/palace/boundarymode.py @@ -135,10 +135,10 @@ def validate_config(self) -> ValidationResult: "Use set_cross_section('x=') or set_cross_section('y=')." ) - if self.ports or self.cpw_ports or self.wave_ports: + if self.wave_ports: errors.append( "Boundary mode uses cross_section-only native 2D meshing. " - "add_port(), add_cpw_port(), and add_wave_port() are not supported." + "add_wave_port() is not supported." ) return ValidationResult( diff --git a/src/gsim/palace/mesh/config_generator.py b/src/gsim/palace/mesh/config_generator.py index 92949aee..a02032b5 100644 --- a/src/gsim/palace/mesh/config_generator.py +++ b/src/gsim/palace/mesh/config_generator.py @@ -490,6 +490,11 @@ def _via_touches(via_name: str, conductor_layer_name: str) -> bool: boundaries["Conductivity"] = conductors if pec_attrs: boundaries["PEC"] = {"Attributes": sorted(set(pec_attrs))} + # Postprocessing-only voltage/impedance paths (mode-V.csv / mode-Z.csv). + # These do not load the 2D eigenproblem. + mode_postprocessing = (hints or {}).get("_mode_postprocessing") + if mode_postprocessing: + boundaries["Postprocessing"] = mode_postprocessing else: lumped_ports: list[dict[str, object]] = [] diff --git a/src/gsim/palace/models/ports.py b/src/gsim/palace/models/ports.py index a066369b..0f4025dc 100644 --- a/src/gsim/palace/models/ports.py +++ b/src/gsim/palace/models/ports.py @@ -56,10 +56,54 @@ class PortConfig(BaseModel): "Positive = away from boundary, into conductor.", ) + # BoundaryMode postprocessing (2D voltage/impedance paths). These paths do + # not affect the 2D eigenproblem; Palace uses them only to post-process the + # mode voltage (mode-V.csv) and characteristic impedance (mode-Z.csv). + voltage_path: list[list[float]] | None = Field( + default=None, + description="Open signal->ground coordinate path (um) for BoundaryMode " + "voltage/impedance postprocessing. Points may be 2D (cross-section " + "coordinates h, v) or 3D (layout x, y, z).", + ) + current_path: list[list[float]] | None = Field( + default=None, + description="Closed-loop coordinate path (um) for the BoundaryMode " + "current line integral (impedance postprocessing only).", + ) + nsamples: int = Field( + default=100, + ge=1, + description="Number of samples for the BoundaryMode line integrals.", + ) + center: tuple[float, float] | None = Field( + default=None, + description="Explicit (x, y) port center (um). Used to auto-derive a " + "BoundaryMode voltage path when voltage_path is not given.", + ) + orientation: float = Field( + default=0.0, + description="Port orientation in degrees (0 = +x).", + ) + width: float | None = Field( + default=None, + gt=0, + description="Port width (um). Used to auto-derive a BoundaryMode " + "voltage path when voltage_path is not given.", + ) + order: int = Field( + default=0, + description="Declaration order across lumped and CPW ports. Used to " + "index BoundaryMode postprocessing entries deterministically.", + ) + @model_validator(mode="after") def validate_layer_config(self) -> Self: """Validate layer configuration based on geometry type.""" - if self.geometry == "inplane" and self.layer is None: + if ( + self.geometry == "inplane" + and self.layer is None + and self.voltage_path is None + ): raise ValueError("Inplane ports require 'layer' to be specified") if self.geometry == "via" and ( self.from_layer is None or self.to_layer is None @@ -115,6 +159,39 @@ def _default_offset(self) -> Self: impedance: float = Field(default=50.0, gt=0) excited: bool = True + # BoundaryMode postprocessing (2D voltage/impedance paths). + voltage_paths: list[list[list[float]]] | None = Field( + default=None, + description="Explicit open signal->ground coordinate paths (um) for " + "BoundaryMode postprocessing, one per CPW gap. Points may be 2D " + "(cross-section coordinates h, v) or 3D (layout x, y, z). When omitted " + "and a port center is available, the two gap paths are auto-derived.", + ) + current_path: list[list[float]] | None = Field( + default=None, + description="Closed-loop coordinate path (um) for the BoundaryMode " + "current line integral (impedance postprocessing only).", + ) + nsamples: int = Field( + default=100, + ge=1, + description="Number of samples for the BoundaryMode line integrals.", + ) + center: tuple[float, float] | None = Field( + default=None, + description="Explicit (x, y) signal-center (um). Used to auto-derive " + "BoundaryMode gap voltage paths when voltage_paths is not given.", + ) + orientation: float = Field( + default=0.0, + description="Port orientation in degrees (0 = +x).", + ) + order: int = Field( + default=0, + description="Declaration order across lumped and CPW ports. Used to " + "index BoundaryMode postprocessing entries deterministically.", + ) + class TerminalConfig(BaseModel): """Configuration for a terminal (for electrostatic capacitance extraction). diff --git a/src/gsim/palace/results.py b/src/gsim/palace/results.py index 6981bf85..4f66683a 100644 --- a/src/gsim/palace/results.py +++ b/src/gsim/palace/results.py @@ -42,6 +42,11 @@ class ModeMetrics(TypedDict): eta_eff: complex +_MODE_Z_COL_RE = re.compile(r"^(Z_PV|Z_VI|L_PV|C_PV|L_VI|C_VI)\[(\d+)\]") +_MODE_V_RE_COL_RE = re.compile(r"^Re\{V\[(\d+)\]\}") +_MODE_V_IM_COL_RE = re.compile(r"^Im\{V\[(\d+)\]\}") + + class PalaceTextResults: """Parsed Palace text output files with pretty-print helpers. @@ -66,6 +71,10 @@ def __init__( self.json_data = json_data self.text_data = text_data self.modes = self._parse_modes() + # BoundaryMode postprocessing results (mode-Z.csv / mode-V.csv): + # {index: {mode: metrics}}. + self.mode_impedance = self._parse_mode_impedance() + self.mode_voltages = self._parse_mode_voltages() @staticmethod def _to_float(value: object) -> float: @@ -142,6 +151,90 @@ def _parse_modes(self) -> dict[int, ModeMetrics]: return modes + def _parse_mode_impedance(self) -> dict[int, dict[int, dict[str, float]]]: + """Parse ``mode-Z.csv`` into ``{index: {mode: metrics}}``. + + Palace writes one row per mode with columns such as + ``Z_PV[1] (Ohm)``, ``Z_VI[1] (Ohm)``, ``L_PV[1] (H/m)`` and + ``C_PV[1] (F/m)`` for each postprocessing impedance index. + """ + rows = self.csv_tables.get("mode-Z.csv", []) + result: dict[int, dict[int, dict[str, float]]] = {} + + for idx, raw_row in enumerate(rows, start=1): + row = { + str(k).strip(): str(v).strip() + for k, v in raw_row.items() + if k is not None + } + mode_id_raw = self._to_float(row.get("m")) + mode_id = int(mode_id_raw) if np.isfinite(mode_id_raw) else idx + + for column, raw_value in row.items(): + match = _MODE_Z_COL_RE.match(column) + if match is None: + continue + quantity, index = match.group(1), int(match.group(2)) + value = self._to_float(raw_value) + if not np.isfinite(value): + continue + result.setdefault(index, {}).setdefault(mode_id, {})[quantity] = value + + return result + + def _parse_mode_voltages(self) -> dict[int, dict[int, complex]]: + """Parse ``mode-V.csv`` into ``{index: {mode: complex voltage}}``.""" + rows = self.csv_tables.get("mode-V.csv", []) + re_parts: dict[int, dict[int, float]] = {} + im_parts: dict[int, dict[int, float]] = {} + + for idx, raw_row in enumerate(rows, start=1): + row = { + str(k).strip(): str(v).strip() + for k, v in raw_row.items() + if k is not None + } + mode_id_raw = self._to_float(row.get("m")) + mode_id = int(mode_id_raw) if np.isfinite(mode_id_raw) else idx + + for column, raw_value in row.items(): + value = self._to_float(raw_value) + re_match = _MODE_V_RE_COL_RE.match(column) + im_match = _MODE_V_IM_COL_RE.match(column) + if re_match is not None and np.isfinite(value): + index = int(re_match.group(1)) + re_parts.setdefault(index, {})[mode_id] = value + elif im_match is not None and np.isfinite(value): + index = int(im_match.group(1)) + im_parts.setdefault(index, {})[mode_id] = value + + result: dict[int, dict[int, complex]] = {} + for index, modes in re_parts.items(): + for mode_id, re_val in modes.items(): + im_val = im_parts.get(index, {}).get(mode_id, 0.0) + result.setdefault(index, {})[mode_id] = complex(re_val, im_val) + return result + + def characteristic_impedance( + self, *, index: int = 1, mode: int = 1, quantity: str = "Z_PV" + ) -> float | None: + """Return a characteristic-impedance quantity from ``mode-Z.csv``. + + Args: + index: Postprocessing impedance index (``Z_PV[i]``). + mode: Mode number (the ``m`` column). + quantity: One of ``"Z_PV"``, ``"Z_VI"``, ``"L_PV"``, ``"C_PV"``, + ``"L_VI"``, ``"C_VI"``. + + Returns: + The value, or ``None`` when unavailable. + """ + return self.mode_impedance.get(index, {}).get(mode, {}).get(quantity) + + def mode_voltage(self, *, index: int = 1, mode: int = 1) -> complex | None: + """Return the complex mode voltage from ``mode-V.csv``.""" + return self.mode_voltages.get(index, {}).get(mode) + @overload def __getitem__(self, key: Literal["modes"]) -> dict[int, ModeMetrics]: ... @@ -216,6 +309,21 @@ def _pretty_text(self, *, max_rows: int = 8, max_lines: int = 12) -> str: f"n_eff = {self._format_complex(mode['n_eff'])}, " f"eta_eff ~= {self._format_complex(mode['eta_eff'], sci=False)}" ) + + for index in sorted(self.mode_impedance): + for mode_id in sorted(self.mode_impedance[index]): + metrics = self.mode_impedance[index][mode_id] + parts = [f"{name} = {value:.6g}" for name, value in metrics.items()] + lines.append(f" Z[{index}] mode {mode_id}: " + ", ".join(parts)) + + for index in sorted(self.mode_voltages): + for mode_id in sorted(self.mode_voltages[index]): + voltage = self.mode_voltages[index][mode_id] + lines.append( + f" V[{index}] mode {mode_id}: " + f"{self._format_complex(voltage, sci=False)} V" + ) + return "\n".join(lines) def print(self, *, max_rows: int = 8, max_lines: int = 12) -> None: diff --git a/tests/palace/test_config_dispersion.py b/tests/palace/test_config_dispersion.py index c7b3acb3..02dc0443 100644 --- a/tests/palace/test_config_dispersion.py +++ b/tests/palace/test_config_dispersion.py @@ -203,3 +203,38 @@ def test_no_frequency_resolution_without_boundary_config(self, tmp_path): absorbing_boundary=False, ) assert self._material_permittivity(config_path) == pytest.approx(12.1) + + def test_boundarymode_postprocessing_hint_emitted(self, tmp_path): + """Postprocessing voltage/impedance paths land under Boundaries.""" + postprocessing = { + "Impedance": [ + { + "Index": 1, + "VoltagePath": [[-19.8, 0.11], [-20.2, 0.11]], + "NSamples": 200, + } + ], + "Voltage": [ + { + "Index": 1, + "VoltagePath": [[-19.8, 0.11], [-20.2, 0.11]], + "NSamples": 200, + } + ], + } + boundary = BoundaryModeConfig(freq=50e9, num_modes=2, save=1) + config_path = generate_palace_config( + groups=self._groups(), + ports=[], + port_info=[], + stack=self._stack_with_core(), + output_path=tmp_path, + model_name="palace", + fmax=100e9, + simulation_type="boundarymode", + boundary_mode_config=boundary, + absorbing_boundary=False, + hints={"_mode_postprocessing": postprocessing}, + ) + config = json.loads(config_path.read_text()) + assert config["Boundaries"]["Postprocessing"] == postprocessing diff --git a/tests/palace/test_results.py b/tests/palace/test_results.py index 49be5c8c..65627124 100644 --- a/tests/palace/test_results.py +++ b/tests/palace/test_results.py @@ -775,3 +775,66 @@ def test_no_text_files_raises(self, tmp_path: Path) -> None: (tmp_path / "output" / "palace").mkdir(parents=True) with pytest.raises(FileNotFoundError, match="parseable"): load_text_results(tmp_path) + + +@pytest.fixture +def postproc_results_dir(tmp_path: Path) -> Path: + """BoundaryMode output with impedance and voltage postprocessing CSVs.""" + palace_dir = tmp_path / "output" / "palace" + palace_dir.mkdir(parents=True) + + (palace_dir / "mode-kn.csv").write_text( + "m,Re{kn} (1/m),Im{kn} (1/m),Re{n_eff},Im{n_eff}\n1,2.0,0.0,1.50,0.00\n" + ) + (palace_dir / "mode-Z.csv").write_text( + " m, Z_PV[1] (Ohm), L_PV[1] (H/m)," + " C_PV[1] (F/m), Z_PV[2] (Ohm)," + " Z_VI[1] (Ohm), L_VI[1] (H/m)\n" + " 1.00e+00, +3.878574108173e+01, +3.230530779000e-07," + " +2.147482805724e-10, +5.000000000000e+01," + " +1.230000000000e+01, +1.000000000000e-07\n" + ) + (palace_dir / "mode-V.csv").write_text( + " m, Re{V[1]} (V), Im{V[1]} (V)\n" + " 1.00e+00, -4.389238602922e+00, -7.635835463547e+00\n" + ) + return tmp_path + + +class TestModePostprocessingResults: + """Tests for mode-Z.csv / mode-V.csv postprocessing parsing.""" + + def test_impedance_parsed_per_index(self, postproc_results_dir: Path) -> None: + out = load_text_results(postproc_results_dir) + assert set(out.mode_impedance) == {1, 2} + assert out.characteristic_impedance(index=1, mode=1) == pytest.approx( + 3.878574108173e01 + ) + assert out.characteristic_impedance(index=1, mode=1, quantity="L_PV") == ( + pytest.approx(3.230530779000e-07) + ) + assert out.characteristic_impedance(index=2, mode=1) == pytest.approx(50.0) + assert out.characteristic_impedance( + index=1, mode=1, quantity="Z_VI" + ) == pytest.approx(12.3) + assert out.characteristic_impedance( + index=1, mode=1, quantity="L_VI" + ) == pytest.approx(1.0e-07) + + def test_voltage_parsed_complex(self, postproc_results_dir: Path) -> None: + out = load_text_results(postproc_results_dir) + voltage = out.mode_voltage(index=1, mode=1) + assert voltage == complex(-4.389238602922, -7.635835463547) + + def test_missing_quantity_returns_none(self, postproc_results_dir: Path) -> None: + out = load_text_results(postproc_results_dir) + assert out.characteristic_impedance(index=9, mode=1) is None + assert out.mode_voltage(index=9, mode=1) is None + + def test_pretty_text_includes_postprocessing( + self, postproc_results_dir: Path + ) -> None: + out = load_text_results(postproc_results_dir) + text = str(out) + assert "Z[1] mode 1:" in text + assert "V[1] mode 1:" in text diff --git a/tests/palace/test_sim_classes.py b/tests/palace/test_sim_classes.py index a11f7bb6..5afceaa4 100644 --- a/tests/palace/test_sim_classes.py +++ b/tests/palace/test_sim_classes.py @@ -10,9 +10,11 @@ import sys from pathlib import Path from types import SimpleNamespace +from typing import cast import pytest +from gsim.common import LayerStack from gsim.palace import BoundaryModeSim, DrivenSim, EigenmodeSim, ElectrostaticSim from gsim.palace.models import MeshConfig @@ -206,6 +208,80 @@ def test_set_boundary_mode_updates_model(self): assert cfg.solver_type == "SLEPc" +class TestBoundaryModePostprocessing: + """BoundaryMode voltage/impedance postprocessing path derivation.""" + + @staticmethod + def _stack() -> LayerStack: + return cast( + LayerStack, + SimpleNamespace( + layers={ + "metal1": SimpleNamespace(zmin=1.1, zmax=2.1), + "p_rib": SimpleNamespace(zmin=0.0, zmax=0.22), + } + ), + ) + + def test_cpw_gap_paths_auto_derived(self): + """A CPW port yields one voltage path per gap in cross-section coords.""" + sim = BoundaryModeSim() + sim.set_cross_section("x=0") + sim.add_cpw_port( + "input", + layer="metal1", + s_width=20.0, + gap_width=20.0, + center=(0.0, 0.0), + orientation=0.0, + ) + post = sim._build_boundarymode_postprocessing(self._stack(), sim.cross_section) + assert len(post["Impedance"]) == 2 + assert len(post["Voltage"]) == 2 + assert post["Impedance"][0]["VoltagePath"] == [[10.0, 1.6], [30.0, 1.6]] + assert post["Impedance"][1]["VoltagePath"] == [[-10.0, 1.6], [-30.0, 1.6]] + assert post["Voltage"][0]["Index"] == 1 + + def test_single_port_explicit_path_projected_from_3d(self): + """3D layout path points are projected onto an x-normal cross-section.""" + sim = BoundaryModeSim() + sim.set_cross_section("x=0") + sim.add_port( + "junction", + voltage_path=[[1.0, -19.8, 0.11], [1.0, -20.2, 0.11]], + nsamples=200, + ) + post = sim._build_boundarymode_postprocessing(self._stack(), sim.cross_section) + entry = post["Impedance"][0] + assert entry["VoltagePath"] == [[-19.8, 0.11], [-20.2, 0.11]] + assert entry["NSamples"] == 200 + # Same path is also reported to the Voltage postprocessing section. + assert post["Voltage"][0]["VoltagePath"] == entry["VoltagePath"] + + def test_single_port_auto_derived_across_width(self): + """A single lumped port derives a path across its width at layer mid-z.""" + sim = BoundaryModeSim() + sim.set_cross_section("x=0") + sim.add_port( + "junction", + layer="p_rib", + width=0.4, + center=(0.0, -20.0), + orientation=180.0, + ) + post = sim._build_boundarymode_postprocessing(self._stack(), sim.cross_section) + assert post["Impedance"][0]["VoltagePath"] == [[-19.8, 0.11], [-20.2, 0.11]] + + def test_voltage_path_allows_missing_layer(self): + """A postprocessing port with an explicit path does not require a layer.""" + sim = BoundaryModeSim() + sim.set_cross_section("x=0") + sim.add_port("junction", voltage_path=[[-19.8, 0.11], [-20.2, 0.11]]) + # Only the (expected) missing geometry error remains, no layer error. + errors = sim.validate_config().errors + assert not any("require 'layer'" in e for e in errors) + + class TestMixinMethods: """Test mixin methods work on all simulation classes.""" From 1c6d2008526e5c0a26b9819c7f0e9fc78a81dd16 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Thu, 17 Sep 2026 20:25:17 -0300 Subject: [PATCH 09/14] fix(pn-junction): eliminate GDS snap slivers and resolve narrow strips Draw P/N/junction/doping/segmented rectangles as grid-snapped explicit polygons so shared bounds land on bit-identical vertices. Independent size+centre GDS snapping previously left 1 nm sliver sheets between adjacent regions (e.g. N-top -20102 vs J-bottom -20101); the resulting needle elements segfaulted Palace BoundaryMode during init. Add min_width_um representation guard (auto falls back to lumped capacitance, forced high_res raises) and a strip-width-keyed mesh-size request (W/4) consumed by the native-2D mesher via per-volume Threshold fields. 15 new tests in tests/common/test_pn_junction.py. --- src/gsim/common/stack/pn_junction.py | 182 ++++++++++++++++++++++++--- src/gsim/palace/mesh/generator.py | 134 ++++++++++++++++++-- src/gsim/palace/mesh/gmsh_utils.py | 24 ++-- tests/common/test_pn_junction.py | 170 +++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 32 deletions(-) diff --git a/src/gsim/common/stack/pn_junction.py b/src/gsim/common/stack/pn_junction.py index 8e2ea876..3a37320a 100644 --- a/src/gsim/common/stack/pn_junction.py +++ b/src/gsim/common/stack/pn_junction.py @@ -490,6 +490,7 @@ def make_doping_profile( zmax: float, permittivity: float = 11.9, fmax: float = 200e9, + snap_grid_um: float | None = 0.001, mesh_resolution: str | float = "fine", ) -> dict[str, dict[str, Any]]: """Add contiguous doping regions beside a rib and build layer/material specs. @@ -518,6 +519,10 @@ def make_doping_profile( 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). + snap_grid_um: Grid (um) the strip bounds are snapped to before + drawing (``None`` disables). Snapped explicit polygons keep + adjacent strips on bit-identical shared edges so no 1 nm GDS + snap slivers appear between them. mesh_resolution: Mesh resolution assigned to the generated ``Layer``. Returns: @@ -549,13 +554,27 @@ def make_doping_profile( 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 + raw0, raw1 = pos, pos + sign * width + centre = _add_rect( + comp, + length=length, + y0=min(raw0, raw1), + y1=max(raw0, raw1), + gds_layer=gds_layer, + snap_grid_um=snap_grid_um, + ) side_centres.append(centre) side_specs[name] = (gds_layer, sigma) - pos += sign * width + # Continue from the snapped bound so the next strip starts + # exactly where this one ended (no drift, no slivers). + if snap_grid_um is not None: + pos = ( + _snap_to_grid(max(raw0, raw1), snap_grid_um) + if sign > 0 + else _snap_to_grid(min(raw0, raw1), snap_grid_um) + ) + else: + pos += sign * width centres[side] = side_centres if not side_specs: @@ -597,6 +616,11 @@ def _as_junction_config( return PNJunctionConfig.model_validate(junction) +def _snap_to_grid(value_um: float, grid_um: float) -> float: + """Snap a coordinate to multiples of ``grid_um`` (round-half-away).""" + return math.floor(value_um / grid_um + 0.5) * grid_um + + def _add_rect( comp: gf.Component, *, @@ -604,10 +628,32 @@ def _add_rect( y0: float, y1: float, gds_layer: tuple[int, int], + snap_grid_um: float | None = 0.001, ) -> float: - """Draw a rectangle spanning ``[y0, y1]`` and return its y-centre.""" - rect = comp << gf.c.rectangle((length, y1 - y0), layer=gds_layer) - rect.y = (y0 + y1) / 2 + """Draw a rectangle spanning ``[y0, y1]`` and return its y-centre. + + Bounds are snapped to ``snap_grid_um`` (``None`` disables) and the + rectangle is emitted as an explicit polygon over ``x in [0, length]`` + (matching the previous ``gf.c.rectangle`` placement). Explicit + snapped polygons — rather than size+centre placement, which snaps + size and centre independently per rectangle — guarantee that + rectangles sharing a bound land on bit-identical vertices, so the + mesher never sees 1 nm snap slivers between adjacent regions. + """ + if snap_grid_um is not None: + if snap_grid_um <= 0: + raise ValueError("snap_grid_um must be positive.") + y0 = _snap_to_grid(y0, snap_grid_um) + y1 = _snap_to_grid(y1, snap_grid_um) + if y1 <= y0: + raise ValueError( + f"Non-positive rectangle y-span [{y0:.6g}, {y1:.6g}] um on " + f"layer {gds_layer}." + ) + comp.add_polygon( + [(0.0, y0), (length, y0), (length, y1), (0.0, y1)], + layer=gds_layer, + ) return (y0 + y1) / 2 @@ -626,6 +672,9 @@ def make_pn_junction_profile( fmax: float = 200e9, mode: Literal["auto", "capacitance", "high_res"] = "auto", mode_fraction: float = JUNCTION_MODE_FRACTION, + min_width_um: float = 0.0, + junction_mesh_size_um: float | None = None, + snap_grid_um: float | None = 0.001, mesh_resolution: str | float = "fine", ) -> dict[str, dict[str, Any]]: """Build P / depletion-junction / N rib regions around ``center_y``. @@ -649,7 +698,17 @@ def make_pn_junction_profile( With ``mode="auto"`` the choice falls out of :func:`select_junction_mode`: the strip is meshed only when ``W >= mode_fraction * min(P flank, N flank)``, where - each flank is ``rib_width / 2``. + each flank is ``rib_width / 2``. When ``min_width_um > 0`` the strip is + additionally meshed only if every drawn rectangle (the depletion strip + *and* both trimmed P/N flanks) is at least ``min_width_um`` wide; + otherwise the lumped-capacitance representation is used so the mesher + never sees an unresolvable sliver. The drawn strip always spans exactly + ``[center_y - xn, center_y + xp]`` — widths are never distorted, only + the representation choice changes. With ``mode="high_res"`` (forced) + the true widths are always drawn; a ``ValueError`` is raised instead + when the partition would violate ``min_width_um`` (or produce a + non-positive flank, e.g. under strongly asymmetric doping where the + depletion spills past a rib half). Args: comp: gdsfactory component the rectangles are added to. @@ -669,6 +728,22 @@ def make_pn_junction_profile( fmax: Upper frequency of the Drude-model validity range (Hz). mode: ``"auto"``, ``"capacitance"`` or ``"high_res"``. mode_fraction: Auto-mode threshold fraction (~1/5 default). + min_width_um: Minimum drawn width (um) for the depletion strip and + both trimmed P/N flanks in ``"high_res"`` mode (default 0.0 = + no constraint, preserving prior behaviour). Tie this to the + mesh resolution (e.g. ~2x the refined mesh size) so the + mesher never sees an unresolvable sliver. + junction_mesh_size_um: Target mesh size (um) requested for the + depletion strip in ``"high_res"`` mode. Defaults to ``None``, + which requests ``W / 4`` (about four elements across the + strip) so the mesher resolves the strip with well-shaped + elements instead of slivers. Pass an explicit size to + override. Recorded on the junction ``Layer`` as a numeric + ``mesh_resolution`` for the BoundaryMode mesher to consume. + snap_grid_um: Grid (um) the region bounds are snapped to before + drawing (``None`` disables). Shared bounds then land on + bit-identical vertices, so no 1 nm GDS snap slivers appear + between the P / junction / N rectangles. mesh_resolution: Mesh resolution assigned to the generated layers. Returns: @@ -698,7 +773,17 @@ def make_pn_junction_profile( f"{rib_width:.4g} um rib." ) + if min_width_um < 0: + raise ValueError("min_width_um must be non-negative.") + + xp, xn = cfg.xp_um, cfg.xn_um flank_um = rib_width / 2 + # Widths the high-res partition would draw (may be non-positive when + # the depletion spills past a rib half under asymmetric doping). + strip_w_um = xn + xp + n_trim_um = flank_um - xn + p_trim_um = flank_um - xp + if mode == "auto": mode = select_junction_mode( cfg.w_um, flank_um, flank_um, fraction=mode_fraction @@ -707,8 +792,39 @@ def make_pn_junction_profile( f"W={cfg.w_um:.4g} um vs threshold " f"{mode_fraction * flank_um:.4g} um (= {mode_fraction} * flank)" ) + if mode == "high_res": + limiting = min(strip_w_um, n_trim_um, p_trim_um) + if limiting <= 0: + mode = "capacitance" + reason = ( + f"depletion spills past a rib half " + f"(trimmed flanks {n_trim_um:.4g}/{p_trim_um:.4g} um); " + f"using lumped capacitance" + ) + elif limiting < min_width_um: + mode = "capacitance" + reason = ( + f"narrowest rect {limiting:.4g} um < min_width_um " + f"{min_width_um:.4g} um; using lumped capacitance" + ) else: reason = f"forced by caller (mode={mode!r})" + + if mode == "high_res": + limiting = min(strip_w_um, n_trim_um, p_trim_um) + if limiting <= 0: + raise ValueError( + f"mode='high_res' would draw a non-positive rectangle " + f"(strip {strip_w_um:.4g} um, trimmed flanks " + f"{n_trim_um:.4g}/{p_trim_um:.4g} um): the depletion spills " + f"past a rib half. Use mode='capacitance' instead." + ) + if limiting < min_width_um: + raise ValueError( + f"mode='high_res' would draw a {limiting:.4g} um rectangle, " + f"below min_width_um={min_width_um:.4g} um. Use " + f"mode='capacitance', reduce min_width_um, or refine the mesh." + ) logger.info("PN junction mode: %s (%s)", mode, reason) result: dict[str, dict[str, Any]] = { @@ -732,13 +848,16 @@ def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: mesh_resolution=mesh_resolution, ) - xp, xn = cfg.xp_um, cfg.xn_um - # N region: lower half, trimmed by xn when the strip is meshed. n_y0 = center_y - flank_um n_y1 = center_y if mode == "capacitance" else center_y - xn centres["n"] = _add_rect( - comp, length=length, y0=n_y0, y1=n_y1, gds_layer=tuple(n_layer) + comp, + length=length, + y0=n_y0, + y1=n_y1, + gds_layer=tuple(n_layer), + snap_grid_um=snap_grid_um, ) layer_specs[n_name] = _doped_spec(n_name, tuple(n_layer), n_sigma) @@ -746,7 +865,12 @@ def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: p_y0 = center_y if mode == "capacitance" else center_y + xp p_y1 = center_y + flank_um centres["p"] = _add_rect( - comp, length=length, y0=p_y0, y1=p_y1, gds_layer=tuple(p_layer) + comp, + length=length, + y0=p_y0, + y1=p_y1, + gds_layer=tuple(p_layer), + snap_grid_um=snap_grid_um, ) layer_specs[p_name] = _doped_spec(p_name, tuple(p_layer), p_sigma) @@ -771,7 +895,17 @@ def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: y0=center_y - xn, y1=center_y + xp, gds_layer=tuple(j_layer), + snap_grid_um=snap_grid_um, ) + if junction_mesh_size_um is None: + # About four elements across the strip: narrow enough to pair + # up the node rows on both strip edges (no slivers), coarse + # enough to keep the element count bounded (~1/W scaling). + strip_mesh_size_um = (xn + xp) / 4.0 + else: + if junction_mesh_size_um <= 0: + raise ValueError("junction_mesh_size_um must be positive.") + strip_mesh_size_um = junction_mesh_size_um layer_specs[j_name] = Layer( name=j_name, gds_layer=tuple(j_layer), @@ -780,7 +914,7 @@ def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: thickness=ztop - zmin, material=j_name, layer_type="dielectric", - mesh_resolution=mesh_resolution, + mesh_resolution=strip_mesh_size_um, ) # Depleted silicon has no free carriers: pure real permittivity. materials[j_name] = MaterialProperties( @@ -794,6 +928,8 @@ def _doped_spec(name: str, gds_layer: tuple[int, int], _sigma: float) -> Layer: "mode": mode, "selection_reason": reason, } + if mode == "high_res": + result["junction"]["strip_mesh_size_um"] = layer_specs[j_name].mesh_resolution return result @@ -1132,6 +1268,7 @@ def make_segmented_junction_profile( n_gds_start: tuple[int, int] = (20, 1), zmin: float = 0.0, zmax: float | None = None, + snap_grid_um: float | None = 0.001, mesh_resolution: str | float = "fine", ) -> dict[str, dict[str, Any]]: """Bin the rib into fine strips sampling the 1D Sze permittivity. @@ -1169,6 +1306,9 @@ def make_segmented_junction_profile( n_gds_start: ``(layer, datatype)`` of ``n_1``. zmin: Bottom z of the strips (um). zmax: Top z of the strips (um); defaults to ``zmin + 0.22``. + snap_grid_um: Grid (um) the strip edges are snapped to before + drawing (``None`` disables), keeping adjacent strips on + bit-identical shared edges. mesh_resolution: Mesh resolution assigned to the layers. Returns: @@ -1200,9 +1340,16 @@ def make_segmented_junction_profile( ) half = rib_width / 2.0 + if snap_grid_um is not None and snap_grid_um <= 0: + raise ValueError("snap_grid_um must be positive.") # (name, y0, y1, gds, side) ordered junction-outward on each side. p_edges = np.linspace(center_y, center_y + half, n_p + 1) n_edges = np.linspace(center_y - half, center_y, n_n + 1) + if snap_grid_um is not None: + # Snap shared edges once so adjacent strips land on bit-identical + # vertices (no 1 nm GDS snap slivers between strips). + p_edges = np.array([_snap_to_grid(float(v), snap_grid_um) for v in p_edges]) + n_edges = np.array([_snap_to_grid(float(v), snap_grid_um) for v in n_edges]) strips: list[tuple[str, float, float, tuple[int, int], str]] = [ ( f"{p_prefix}{i + 1}", @@ -1248,7 +1395,12 @@ def make_segmented_junction_profile( for k, (name, y0, y1, gds_layer, side) in enumerate(strips): yc = centres[name] = _add_rect( - comp, length=length, y0=y0, y1=y1, gds_layer=gds_layer + comp, + length=length, + y0=y0, + y1=y1, + gds_layer=gds_layer, + snap_grid_um=snap_grid_um, ) layer_specs[name] = Layer( name=name, diff --git a/src/gsim/palace/mesh/generator.py b/src/gsim/palace/mesh/generator.py index 950310e2..8ca8a555 100644 --- a/src/gsim/palace/mesh/generator.py +++ b/src/gsim/palace/mesh/generator.py @@ -983,6 +983,79 @@ def _setup_mesh_fields( gmsh_utils.finalize_mesh_fields(field_ids) +def _fine_size_targets( + groups: dict, + stack, + refined_mesh_size: float, +) -> dict[str, float]: + """Map volume names to requested fine mesh sizes (no gmsh needed). + + Volumes whose stack ``Layer`` carries a numeric ``mesh_resolution`` + smaller than the global ``refined_mesh_size`` (e.g. the PN-junction + depletion strip, whose width can be far below the mesh target) + request dedicated refinement. Returns ``{volume_name: size_um}``. + """ + layers = getattr(stack, "layers", {}) or {} + targets: dict[str, float] = {} + for name in groups.get("volumes", {}): + resolution = getattr(layers.get(name), "mesh_resolution", None) + if isinstance(resolution, bool) or not isinstance(resolution, (int, float)): + continue + size_um = float(resolution) + if 0.0 < size_um < refined_mesh_size: + targets[name] = size_um + return targets + + +def _collect_fine_size_requests( + groups: dict, + stack, + refined_mesh_size: float, +) -> list[tuple[list[int], float]]: + """Collect per-volume fine mesh-size requests for native 2D meshing. + + Only curves shared with another dielectric volume (the registered + ``interface_surfaces``) are refined — domain-wall curves are excluded + so the fine zone never leaks onto the simulation boundary. + + Requires an active gmsh session; returns ``[]`` when gmsh is not + initialized. Each entry is ``(curve_tags, size_um)``. + """ + if not gmsh.isInitialized(): + return [] + targets = _fine_size_targets(groups, stack, refined_mesh_size) + + internal_curves: set[int] = set() + for iface in groups.get("interface_surfaces", {}).values(): + for tag in iface.get("tags", []): + internal_curves.add(int(tag)) + if not internal_curves: + return [] + + requests: list[tuple[list[int], float]] = [] + for name, vol_info in groups.get("volumes", {}).items(): + if name not in targets: + continue + size_um = targets[name] + curves: set[int] = set() + for stag in vol_info.get("tags", []): + try: + boundary = gmsh.model.getBoundary( + [(2, int(stag))], + combined=False, + oriented=False, + recursive=False, + ) + except Exception: + continue + for dim, ctag in boundary: + if dim == 1 and int(ctag) in internal_curves: + curves.add(int(ctag)) + if curves: + requests.append((sorted(curves), size_um)) + return requests + + def generate_mesh( component, stack: LayerStack, @@ -1136,16 +1209,63 @@ def generate_mesh( for tag in info.get("tags", []) } ) + # Per-volume fine-size requests (e.g. the PN-junction depletion + # strip): each gets its own Threshold field keyed to the + # requested size so narrow features mesh with several + # well-shaped elements across instead of slivers. + fine_requests = _collect_fine_size_requests( + groups, stack, refined_mesh_size + ) + field_ids: list[int] = [] + next_field_id = 1 if refinement_lines: aggressive_size = max(refined_mesh_size * 0.5, 1e-4) - field_id = gmsh_utils.setup_mesh_refinement( - refinement_lines, - aggressive_size, - max_mesh_size, - sampling=400, - dist_max=max_mesh_size * 0.5, + field_ids.append( + gmsh_utils.setup_mesh_refinement( + refinement_lines, + aggressive_size, + max_mesh_size, + sampling=400, + dist_max=max_mesh_size * 0.5, + distance_id=next_field_id, + threshold_id=next_field_id + 1, + ) + ) + next_field_id += 2 + for curve_tags, size_um in fine_requests: + total_length = 0.0 + for ctag in curve_tags: + try: + bb = gmsh.model.getBoundingBox(1, int(ctag)) + total_length += math.hypot(bb[3] - bb[0], bb[4] - bb[1]) + except Exception: + pass + sampling = ( + min(20000, max(400, math.ceil(total_length / (size_um / 4.0)))) + if total_length > 0 + else 2000 + ) + logger.info( + "Fine mesh request: %d curves at %.4g um (sampling %d)", + len(curve_tags), + size_um, + sampling, + ) + field_ids.append( + gmsh_utils.setup_mesh_refinement( + curve_tags, + size_um, + max_mesh_size, + sampling=sampling, + dist_min=2.0 * size_um, + dist_max=max(1.0, 40.0 * size_um), + distance_id=next_field_id, + threshold_id=next_field_id + 1, + ) ) - gmsh_utils.finalize_mesh_fields([field_id]) + next_field_id += 2 + if field_ids: + gmsh_utils.finalize_mesh_fields(field_ids) else: gmsh.option.setNumber("Mesh.MeshSizeMin", refined_mesh_size) gmsh.option.setNumber("Mesh.MeshSizeMax", max_mesh_size) diff --git a/src/gsim/palace/mesh/gmsh_utils.py b/src/gsim/palace/mesh/gmsh_utils.py index 1139474e..43784469 100644 --- a/src/gsim/palace/mesh/gmsh_utils.py +++ b/src/gsim/palace/mesh/gmsh_utils.py @@ -891,6 +891,8 @@ def setup_mesh_refinement( sampling: int = 200, dist_min: float = 0.0, dist_max: float | None = None, + distance_id: int = 1, + threshold_id: int = 2, ) -> int: """Set up mesh refinement near boundary lines. @@ -901,28 +903,30 @@ def setup_mesh_refinement( sampling: Number of sample points for Distance field evaluation dist_min: Distance where SizeMin applies dist_max: Distance where SizeMax applies (defaults to max_cellsize) + distance_id: Field ID for the Distance field + threshold_id: Field ID for the Threshold field Returns: Field ID for the minimum field """ # Distance field from boundary curves - gmsh.model.mesh.field.add("Distance", 1) - gmsh.model.mesh.field.setNumbers(1, "CurvesList", boundary_line_tags) - gmsh.model.mesh.field.setNumber(1, "Sampling", int(sampling)) + gmsh.model.mesh.field.add("Distance", distance_id) + gmsh.model.mesh.field.setNumbers(distance_id, "CurvesList", boundary_line_tags) + gmsh.model.mesh.field.setNumber(distance_id, "Sampling", int(sampling)) # Threshold field for gradual size transition - gmsh.model.mesh.field.add("Threshold", 2) - gmsh.model.mesh.field.setNumber(2, "InField", 1) - gmsh.model.mesh.field.setNumber(2, "SizeMin", refined_cellsize) - gmsh.model.mesh.field.setNumber(2, "SizeMax", max_cellsize) - gmsh.model.mesh.field.setNumber(2, "DistMin", dist_min) + gmsh.model.mesh.field.add("Threshold", threshold_id) + gmsh.model.mesh.field.setNumber(threshold_id, "InField", distance_id) + gmsh.model.mesh.field.setNumber(threshold_id, "SizeMin", refined_cellsize) + gmsh.model.mesh.field.setNumber(threshold_id, "SizeMax", max_cellsize) + gmsh.model.mesh.field.setNumber(threshold_id, "DistMin", dist_min) gmsh.model.mesh.field.setNumber( - 2, + threshold_id, "DistMax", max_cellsize if dist_max is None else float(dist_max), ) - return 2 + return threshold_id def setup_box_refinement( diff --git a/tests/common/test_pn_junction.py b/tests/common/test_pn_junction.py index 3aefdaa0..053207af 100644 --- a/tests/common/test_pn_junction.py +++ b/tests/common/test_pn_junction.py @@ -37,6 +37,8 @@ from gsim.common.stack.pn_junction import ( NI_SI_300K_CM3, PNJunctionConfig, + _add_rect, + _snap_to_grid, built_in_voltage, carrier_profile_1d, depletion_extents, @@ -52,6 +54,7 @@ select_junction_mode, ) from gsim.palace import BoundaryModeSim +from gsim.palace.mesh.generator import _fine_size_targets # --------------------------------------------------------------------------- # Part 1: depletion model. @@ -402,6 +405,173 @@ def test_layer_specs_reference_materials(self): assert spec.zmax == 0.22 +def _spillover_junction() -> PNJunctionConfig: + """Strongly asymmetric doping: xp overflows the P flank (negative trim).""" + return PNJunctionConfig(na_cm3=1e16, nd_cm3=1e18) + + +class TestMinWidthUm: + def test_default_preserves_prior_behaviour(self): + # Notebook defaults: W = 49.5 nm selects high_res without a guard. + _comp, res = _build( + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18), + junction_region=JUNCTION_REGION, + ) + assert res["junction"]["mode"] == "high_res" + + def test_narrow_strip_falls_back_to_capacitance(self): + # W = 49.5 nm < min_width 50 nm -> lumped C, no sliver drawn. + comp, res = _build( + PNJunctionConfig(na_cm3=1e18, nd_cm3=1e18), + junction_region=JUNCTION_REGION, + min_width_um=0.05, + ) + assert res["junction"]["mode"] == "capacitance" + assert "min_width_um" in res["junction"]["selection_reason"] + assert "junction" not in res["layer_specs"] + polys = comp.get_polygons(layers=(JUNCTION_REGION[1],)) + assert not any(v for v in polys.values()) + + def test_resolvable_strip_stays_high_res(self): + # W = 71.3 nm clears a 50 nm guard; drawn widths are untouched. + comp, res = _build( + _wide_junction(), + junction_region=JUNCTION_REGION, + min_width_um=0.05, + ) + assert res["junction"]["mode"] == "high_res" + rects = _section_rects(comp, res) + widths = [r.y1 - r.y0 for r in rects] + assert min(widths) == pytest.approx(0.0713, abs=5e-3) + assert min(widths) >= 0.05 - 1e-9 + # Drawn strip still spans exactly [cy - xn, cy + xp]. + junc = _wide_junction() + by_name = {r.layer_name: r for r in rects} + assert by_name["junction"].y0 == pytest.approx(CY - junc.xn_um, abs=2e-3) + assert by_name["junction"].y1 == pytest.approx(CY + junc.xp_um, abs=2e-3) + + def test_guard_above_strip_width_falls_back(self): + _comp, res = _build( + _wide_junction(), + junction_region=JUNCTION_REGION, + min_width_um=0.1, + ) + assert res["junction"]["mode"] == "capacitance" + + def test_spillover_falls_back_to_capacitance(self): + _comp, res = _build(_spillover_junction(), junction_region=JUNCTION_REGION) + assert res["junction"]["mode"] == "capacitance" + assert "rib half" in res["junction"]["selection_reason"] + + def test_forced_high_res_below_min_width_raises(self): + with pytest.raises(ValueError, match="min_width_um"): + _build( + _wide_junction(), + mode="high_res", + junction_region=JUNCTION_REGION, + min_width_um=0.1, + ) + + def test_forced_high_res_spillover_raises(self): + with pytest.raises(ValueError, match="non-positive rectangle"): + _build( + _spillover_junction(), + mode="high_res", + junction_region=JUNCTION_REGION, + ) + + def test_negative_min_width_rejected(self): + with pytest.raises(ValueError, match="min_width_um"): + _build(_wide_junction(), min_width_um=-0.01) + + +class TestSnapCoincidence: + """Grid-snapped explicit polygons share bit-identical edges (no slivers).""" + + def test_snap_to_grid(self): + assert _snap_to_grid(0.02475, 0.001) == pytest.approx(0.025) + assert _snap_to_grid(-20.101064, 0.001) == pytest.approx(-20.101) + assert _snap_to_grid(0.2, 0.001) == pytest.approx(0.2) + + def test_shared_bounds_coincide_exactly(self): + # Wide junction: xp/xn land off-grid; N/J and J/P shared bounds + # must coincide exactly through extraction (previously ±1 nm off + # from independent size+centre GDS snapping). + comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + rects = _section_rects(comp, res) + by_name = {r.layer_name: r for r in rects} + assert by_name["n_rib"].y1 == by_name["junction"].y0 + assert by_name["junction"].y1 == by_name["p_rib"].y0 + + def test_snap_grid_none_preserves_exact_floats(self): + comp = gf.Component() + centre = _add_rect( + comp, + length=10.0, + y0=-20.101064, + y1=-19.898936, + gds_layer=(22, 0), + snap_grid_um=None, + ) + assert centre == pytest.approx((-20.101064 - 19.898936) / 2) + + def test_non_positive_span_rejected(self): + comp = gf.Component() + with pytest.raises(ValueError, match="Non-positive rectangle"): + _add_rect(comp, length=10.0, y0=1.0, y1=1.0, gds_layer=(22, 0)) + with pytest.raises(ValueError, match="snap_grid_um"): + _add_rect( + comp, + length=10.0, + y0=0.0, + y1=1.0, + gds_layer=(22, 0), + snap_grid_um=-0.001, + ) + + def test_strip_mesh_size_auto_and_override(self): + _comp, res = _build(_wide_junction(), junction_region=JUNCTION_REGION) + junc = _wide_junction() + assert res["junction"]["strip_mesh_size_um"] == pytest.approx(junc.w_um / 4) + assert res["layer_specs"]["junction"].mesh_resolution == pytest.approx( + junc.w_um / 4 + ) + _comp, res = _build( + _wide_junction(), + junction_region=JUNCTION_REGION, + junction_mesh_size_um=0.03, + ) + assert res["junction"]["strip_mesh_size_um"] == pytest.approx(0.03) + with pytest.raises(ValueError, match="junction_mesh_size_um"): + _build( + _wide_junction(), + junction_region=JUNCTION_REGION, + junction_mesh_size_um=0.0, + ) + + def test_capacitance_has_no_strip_request(self): + _comp, res = _build(_thin_junction()) + assert "strip_mesh_size_um" not in res["junction"] + assert "junction" not in res["layer_specs"] + + def test_fine_size_targets(self): + from types import SimpleNamespace + + groups = {"volumes": {k: {} for k in ("a", "b", "c", "d", "e", "f")}} + stack = SimpleNamespace( + layers={ + "a": SimpleNamespace(mesh_resolution=0.012), # requested + "b": SimpleNamespace(mesh_resolution="fine"), # string: skip + "c": SimpleNamespace(mesh_resolution=0.2), # >= refined: skip + "d": SimpleNamespace(mesh_resolution=0.0), # non-positive: skip + "e": SimpleNamespace(mesh_resolution=True), # bool: skip + # "f" absent from the stack: skip + } + ) + assert _fine_size_targets(groups, stack, 0.05) == {"a": 0.012} + assert _fine_size_targets({}, stack, 0.05) == {} + + class TestValidation: def test_depletion_wider_than_rib_rejected(self): big = PNJunctionConfig(na_cm3=1e16, nd_cm3=1e16, v_reverse=5.0) From 948ce556a4686674600eed26bf4b9d5e5083ed85 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Thu, 17 Sep 2026 20:27:33 -0300 Subject: [PATCH 10/14] fix(pn-junction): run RF BoundaryMode at order 1 for faster solves --- nbs/palace_2d_twmzm.ipynb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index bc3f2385..95599753 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -843,6 +843,8 @@ " f\"{pn_result['junction']['w_um'] * 1e3:.1f} nm meshed as dielectric.\"\n", " )\n", "\n", + "# First-order FEM: halves DOFs vs order 2 and finishes in minutes.\n", + "sim.set_numerical(order=1)\n", "sim.write_config()\n", "print(\"Config written to:\", sim.output_dir)" ] From 868ea1a6c0ce53c7e295ea3a3a72fc18c8e25c27 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 21 Sep 2026 13:38:58 -0300 Subject: [PATCH 11/14] fix(palace): use bundled runtime resolver for implicit executable When neither an explicit palace_executable nor a SIF was configured, run_local set palace_executable="palace" and then resolved it relative to the CWD (e.g. nbs/palace), bypassing the bundled/cached/auto-download resolver entirely. Track the PATH fallback with a sentinel so the resolver runs first and "palace" on PATH stays the last resort. --- src/gsim/palace/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gsim/palace/base.py b/src/gsim/palace/base.py index 296c1bfa..72af7355 100644 --- a/src/gsim/palace/base.py +++ b/src/gsim/palace/base.py @@ -2306,8 +2306,13 @@ def run_local( ) break + # Track whether ``palace_executable`` is only a last-resort PATH + # fallback (rather than a user-supplied path). The bundled/cached + # resolver must run first in that case. + _path_fallback = False if palace_executable is None and palace_sif_path is None: palace_executable = "palace" + _path_fallback = True # palace_executable takes precedence for a simpler API. run_with_apptainer = use_apptainer and palace_executable is None @@ -2356,7 +2361,7 @@ def run_local( resolved_exe: str | Path | None = None lib_dir: Path | None = None - if palace_executable is not None: + if palace_executable is not None and not _path_fallback: # Explicit parameter — resolve to absolute path resolved_exe = Path(palace_executable).expanduser().resolve() else: From ac4326edd81c0b0e63c5ad81e8c26904723de114 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 21 Sep 2026 13:39:01 -0300 Subject: [PATCH 12/14] fix(types): satisfy ty for vector bounds and doping entries Use the existing _scale_vector/_vector_bounds helpers to build fixed-length Vector3 tuples in the FDTD runtime, and replace # type: ignore[arg-type] with explicit cast() narrowing in make_doped_materials so ty accepts the union of 2- and 4-tuples. --- src/gsim/common/stack/materials.py | 4 ++-- src/gsim/fdtd/runtime.py | 30 ++++++++++-------------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/gsim/common/stack/materials.py b/src/gsim/common/stack/materials.py index 2a619a88..a06da319 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 = entry # type: ignore[arg-type] + name, sigma = cast("tuple[str, float]", entry) items.append( ( name, @@ -669,7 +669,7 @@ def make_doped_materials( ) ) elif len(entry) == 4: - items.append(entry) # type: ignore[arg-type] + items.append(cast("tuple[str, float, float, str]", entry)) else: msg = ( "Entries must be (name, sigma) or (name, permittivity, " diff --git a/src/gsim/fdtd/runtime.py b/src/gsim/fdtd/runtime.py index bb26a919..eac01307 100644 --- a/src/gsim/fdtd/runtime.py +++ b/src/gsim/fdtd/runtime.py @@ -351,17 +351,12 @@ def _explicit_gaussian_beam( material_snapshots: Mapping[str, MaterialSnapshot], ) -> GaussianBeamConfig: """Translate an explicitly positioned public Gaussian beam.""" - half_size_nm = tuple(value * 500 for value in source.size_um) - center_nm = tuple(value * 1000 for value in source.center_um) + center_nm = _scale_vector(source.center_um, 1000) + half_size_nm = _scale_vector(source.size_um, 500) + region_min, region_max = _vector_bounds(center_nm, half_size_nm) return GaussianBeamConfig( - region_min=tuple( - center - half - for center, half in zip(center_nm, half_size_nm, strict=True) - ), - region_max=tuple( - center + half - for center, half in zip(center_nm, half_size_nm, strict=True) - ), + region_min=region_min, + region_max=region_max, aperture_normal=source.aperture_normal, propagation_direction=source.propagation_direction, e_polarization=source.e_polarization, @@ -377,19 +372,14 @@ def _plane_monitor_config( material_snapshots: Mapping[str, MaterialSnapshot], ) -> PlaneMonitorConfig: """Translate one public plane-monitor definition.""" - center_nm = tuple(value * 1000 for value in monitor.center_um) - half_size_nm = tuple(value * 500 for value in monitor.size_um) + center_nm = _scale_vector(monitor.center_um, 1000) + half_size_nm = _scale_vector(monitor.size_um, 500) + region_min, region_max = _vector_bounds(center_nm, half_size_nm) fiber_mode = self._fiber_mode_config(monitor.fiber_mode, material_snapshots) return PlaneMonitorConfig( name=monitor.name, - region_min=tuple( - center - half - for center, half in zip(center_nm, half_size_nm, strict=True) - ), - region_max=tuple( - center + half - for center, half in zip(center_nm, half_size_nm, strict=True) - ), + region_min=region_min, + region_max=region_max, normal=monitor.normal, flux=monitor.flux, wavelengths=( From bddb9f090f29d68d1435dc5e6cf2e54814607741 Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 21 Sep 2026 13:39:04 -0300 Subject: [PATCH 13/14] chore(pre-commit): pin Node 20 for markdownlint-cli2 markdownlint-cli2 v0.20.0 requires Node >=20; pin language_version so pre-commit provisions it via nodeenv instead of failing on the system Node 18. --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c55d2836..ffef88bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,7 @@ repos: hooks: - id: markdownlint-cli2 name: markdownlint + language_version: "20.18.0" args: [--config, .github/.markdownlint-cli2.yaml] - repo: https://github.com/executablebooks/mdformat rev: 0.7.22 From 74586aca1e190f1bb5013e2e7c57bb8e34ad6dac Mon Sep 17 00:00:00 2001 From: "Martin D. Maas" Date: Mon, 21 Sep 2026 13:39:07 -0300 Subject: [PATCH 14/14] feat(pn-junction): retune TW-MZM junction to 1 pF/mm at -1 V Set the rib doping to Na=Nd=1e19 cm^-3 at V_R=1 V (positive = reverse), giving V_bi=1.05 V, W=23.2 nm and C_j=9.98 fF (0.998 pF/mm) for the 10 x 0.22 um junction; auto-selection now picks the lumped-capacitance representation. Update PN_RIB_SIGMA to a q*mu*N-consistent 8e4 S/m and refresh the comments/summary (optical loss rises to ~24 dB/cm at 1550 nm). --- nbs/palace_2d_twmzm.ipynb | 48 +++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/nbs/palace_2d_twmzm.ipynb b/nbs/palace_2d_twmzm.ipynb index 95599753..ff262b84 100644 --- a/nbs/palace_2d_twmzm.ipynb +++ b/nbs/palace_2d_twmzm.ipynb @@ -88,18 +88,25 @@ "# All model inputs are user-selectable here: doping concentrations (cm^-3),\n", "# reverse bias V_R [V] (positive = reverse), lattice temperature [K],\n", "# intrinsic carrier concentration ni [cm^-3], and permittivity. The values\n", - "# below are typical for a Si-photonic modulator junction (~1e18 cm^-3 near\n", - "# the metallurgical junction; contacts are handled by the graded slab\n", - "# doping at ~1e20 cm^-3). At this doping W ~ 50 nm is resolvable on the\n", - "# mesh, so auto-selection picks high-res mode.\n", + "# below set the rib doping to 1e19 cm^-3 and a -1 V reverse bias to hit\n", + "# the target depletion capacitance of ~1 pF/mm for a Si-photonic modulator\n", + "# junction. At this doping W ~ 23 nm is far thinner than the rib flank, so\n", + "# auto-selection picks the lumped-capacitance representation (applied as an\n", + "# Impedance boundary). The trade-off is a higher optical loss (~24 dB/cm at\n", + "# 1550 nm) than a typical low-loss rib design; contacts are handled by the\n", + "# graded slab doping at ~1e20 cm^-3.\n", "SI_PERMITTIVITY = 11.9\n", "FMAX_RF_MATERIAL = 200e9 # validity range of the constant-eps doping models (Hz)\n", - "PN_RIB_SIGMA = 1.6e3 # Drude conductivity of the P/N rib regions (S/m),\n", - "# sigma = q*mu*N with mu ~ 1000 cm^2/Vs at N ~ 1e18\n", + "\n", + "# Drude conductivity of the P/N rib regions (S/m): sigma = q*mu*N at\n", + "# N ~ 1e19 (electron ~1.6e5, hole ~7.2e4 with low-field mobilities). A\n", + "# representative value is used for both polarities; at RF, sigma >> w*eps\n", + "# so the exact split is immaterial to the mode.\n", + "PN_RIB_SIGMA = 8.0e4\n", "PN_JUNCTION = {\n", - " \"na_cm3\": 1e18,\n", - " \"nd_cm3\": 1e18,\n", - " \"v_reverse\": 0.0,\n", + " \"na_cm3\": 1.0e19,\n", + " \"nd_cm3\": 1.0e19,\n", + " \"v_reverse\": 1.0,\n", " \"temperature_k\": 300.0,\n", " \"ni_cm3\": 1.5e10,\n", " \"permittivity\": SI_PERMITTIVITY,\n", @@ -178,20 +185,26 @@ ")\n", "print(\n", " f\"C_j = eps_s A / W = {junc.capacitance(LENGTH, RIB_HEIGHT) * 1e15:.2f} fF \"\n", - " f\"(A = {LENGTH} x {RIB_HEIGHT} um)\"\n", + " f\"(A = {LENGTH} x {RIB_HEIGHT} um) = \"\n", + " f\"{junc.capacitance(LENGTH, RIB_HEIGHT) / LENGTH * 1e15:.3f} pF/mm\"\n", ")\n", "print(\n", " f\"Flank size = {flank * 1e3:.0f} nm -> auto mode selects \"\n", " f\"'{junc.select_mode(flank, flank)}'\\n\"\n", ")\n", "\n", - "print(\"How doping moves W across the auto-selection threshold:\")\n", + "print(\"Doping sweep at the configured bias (target 1 pF/mm @ -1 V):\")\n", "for n_cm3 in (1e19, 5e18, 2e18, 1e18):\n", - " j = PNJunctionConfig(na_cm3=n_cm3, nd_cm3=n_cm3)\n", + " j = PNJunctionConfig(\n", + " na_cm3=n_cm3,\n", + " nd_cm3=n_cm3,\n", + " v_reverse=PN_JUNCTION[\"v_reverse\"],\n", + " )\n", " mode = j.select_mode(flank, flank)\n", + " c_pf_mm = j.capacitance(LENGTH, RIB_HEIGHT) / LENGTH * 1e15\n", " print(\n", " f\" Na = Nd = {n_cm3:.1e} cm^-3 : W = {j.w_um * 1e3:6.1f} nm, \"\n", - " f\"C = {j.capacitance(LENGTH, RIB_HEIGHT) * 1e15:6.2f} fF -> {mode}\"\n", + " f\"C = {c_pf_mm:6.3f} pF/mm -> {mode}\"\n", " )" ] }, @@ -1138,9 +1151,9 @@ "| Slab (90 nm) | SLAB90 (3,0) | Si (intrinsic) | 2 |\n", "| PN strips (optical) | P (21,1..8) | free-carrier Si (p_1..p_8) | per-strip σ |\n", "| PN strips (optical) | N (20,1..8) | free-carrier Si (n_1..n_8) | per-strip σ |\n", - "| PN junction (RF P) | P (21,0) | doped Si (p_rib) | 1.6x10^3 |\n", + "| PN junction (RF P) | P (21,0) | doped Si (p_rib) | 8.0x10^4 |\n", "| Depletion strip (RF) | (22,0) | Si (eps 11.9, undoped) | — |\n", - "| PN junction (RF N) | N (20,0) | doped Si (n_rib) | 1.6x10^3 |\n", + "| PN junction (RF N) | N (20,0) | doped Si (n_rib) | 8.0x10^4 |\n", "| P+ graded inner/outer | PP (23,0)/(23,1) | doped Si | 2e4 / 8e4 |\n", "| N+ graded inner/outer | NPP (24,0)/(24,1) | doped Si | 2e4 / 8e4 |\n", "| Vias (S->P+, G->N+) | VIAC/VIA1/VIA2 | W/Al | 3.5x10^7 |\n", @@ -1173,8 +1186,9 @@ " `σ(ω) = N·q·μ/(1 + j·ω·τ)` with `τ = m*·μ/q`\n", " (`m*_ce = 0.26·m0`, `m*_ch = 0.38·m0`); the complex permittivity is\n", " `ε_eff = ε_bg + (σ_n + σ_p)/(j·ω)` evaluated at 1550 nm against the Si\n", - " Sellmeier background. At `1e18 cm⁻³` the quasi-neutral rib carries\n", - " `Δn ≈ −1e-3` with `σ ≈ 0.5 S/m`; the depletion slice stays at `ε_bg`.\n", + " Sellmeier background. At `1e19 cm⁻³` (this design's rib doping) the\n", + " quasi-neutral rib carries `Δn ≈ −1.2e-2`, `σ ≈ 5 S/m` and ~24 dB/cm at\n", + " 1550 nm; the depletion slice stays at `ε_bg`.\n", "- Palace takes `Re(ε) → Permittivity` and `Im(ε) → Conductivity`\n", " (`σ = ω·ε0·ε″`); depleted strips carry no conductivity entry.\n", "\n",