diff --git a/.github/write_layer_stack.py b/.github/write_layer_stack.py new file mode 100644 index 0000000..82cbaad --- /dev/null +++ b/.github/write_layer_stack.py @@ -0,0 +1,569 @@ +"""Generate interactive layer_stack.md for cspdk. + +Reads layer_stack and cross_sections from each band PDK and writes +docs/layer_stack.md with interactive SVG/JS visualizations. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +from cspdk.si220.cband import PDK as PDK_SI220_CBAND +from cspdk.si220.cband.config import PATH +from cspdk.si220.oband import PDK as PDK_SI220_OBAND + +BANDS = [ + ("Si220 C-band", PDK_SI220_CBAND), + ("Si220 O-band", PDK_SI220_OBAND), +] + +_optional = [ + ("Si500", "cspdk.si500"), + ("SiN300", "cspdk.sin300"), + ("Si340", "cspdk.si340"), + ("SiN200", "cspdk.sin200"), + ("Ge-on-Si", "cspdk.ge_on_si"), + ("Si Suspended", "cspdk.si_sus"), +] + +for _label, _mod_path in _optional: + try: + _mod = __import__(_mod_path, fromlist=["PDK"]) + BANDS.append((_label, _mod.PDK)) + except ImportError: + pass + +# --------------------------------------------------------------------------- +# Colour palette +# --------------------------------------------------------------------------- + +MATERIAL_COLORS = { + "si": "#A0A0A0", "silicon": "#A0A0A0", + "sio2": "#FFF8DC", "oxide": "#D0D0D0", + "sin": "#8FBC8F", "sion": "#8FBC8F", + "ge": "#4682B4", "al": "#4CAF50", "cu": "#D0A050", + "w": "#9E9E9E", "tin": "#DDA0DD", "air": "#E8F4FD", + "nclad": "#FFF8DC", "nbox": "#FFF8DC", +} + +XS_MATERIAL_COLORS = { + "si": "#5B8CBE", "silicon": "#5B8CBE", + "sin": "#2E8B57", "sion": "#2E8B57", + "ge": "#4682B4", "al": "#CCC", "cu": "#D0A050", + "tin": "#CD853F", "w": "#9E9E9E", +} + + +def _color_for_layer(material): + mat = (material or "").lower().strip() + return MATERIAL_COLORS.get(mat, "#D0D0D0") + + +def _color_for_xs(material): + mat = (material or "").lower().strip() + return XS_MATERIAL_COLORS.get(mat, "#999") + + +def _infer_layer_type(material, name): + mat = (material or "").lower() + n = (name or "").lower() + if any(m in mat for m in ("al", "cu", "w", "tin", "nickel", "metal")): + return "conductor" + if "via" in n: + return "via" + if "sub" in n or "box" in n: + return "substrate" + return "dielectric" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _gds_layer_number(layer_attr): + if layer_attr is None: + return None + inner = getattr(layer_attr, "layer", layer_attr) + if isinstance(inner, (tuple, list)) and len(inner) >= 1: + try: + return int(inner[0]) + except (ValueError, TypeError): + return None + try: + return int(inner) + except (ValueError, TypeError): + return None + + +def _nice_step(range_val, target_ticks=10): + raw = range_val / target_ticks + mag = 10 ** math.floor(math.log10(raw)) + residual = raw / mag + if residual <= 1.5: + return mag + elif residual <= 3.5: + return 2 * mag + elif residual <= 7.5: + return 5 * mag + return 10 * mag + + +# --------------------------------------------------------------------------- +# Data extraction +# --------------------------------------------------------------------------- + + +def _extract_layers(layer_stack): + layers = [] + for name, level in layer_stack.layers.items(): + thickness = getattr(level, "thickness", 0) or 0 + zmin = getattr(level, "zmin", 0) or 0 + material = getattr(level, "material", "") or "" + gds = _gds_layer_number(getattr(level, "layer", None)) + layers.append({ + "name": name, "gds": gds, "material": material, + "type": _infer_layer_type(material, name), + "zmin": round(zmin, 4), "zmax": round(zmin + thickness, 4), + "thickness": round(thickness, 4), + "color": _color_for_layer(material), + }) + layers.sort(key=lambda l: l["zmin"]) + return layers + + +def _compute_layout(layers, svg_w=550, svg_h=750): + margin = {"top": 60, "bottom": 30, "left": 70, "right": 100} + plot_h = svg_h - margin["top"] - margin["bottom"] + + real = [l for l in layers if abs(l["thickness"]) > 1e-9] + if not real: + return layers, [] + + n = len(real) + row_h = plot_h / max(n, 1) + for i, l in enumerate(real): + l["uy"] = round(margin["top"] + (n - 1 - i) * row_h, 1) + l["uh"] = round(row_h * 0.93, 1) + + all_z = [] + for l in real: + all_z.extend([l["zmin"], l["zmax"]]) + z_min_g = min(all_z) + z_max_g = max(all_z) + z_range = z_max_g - z_min_g or 1.0 + + def z_to_y(z): + frac = (z - z_min_g) / z_range + return margin["top"] + plot_h - frac * plot_h + + for l in real: + overlapping = [ol for ol in real if + ol["zmin"] < l["zmax"] - 1e-6 and ol["zmax"] > l["zmin"] + 1e-6] + l["ncols"] = len(overlapping) + l["col"] = overlapping.index(l) if l in overlapping else 0 + sy_top = z_to_y(l["zmax"]) + sy_bot = z_to_y(l["zmin"]) + l["sy"] = round(sy_top, 1) + l["sh"] = round(max(sy_bot - sy_top, 2), 1) + + ticks = [] + step = _nice_step(z_range) + z = math.floor(z_min_g / step) * step + while z <= z_max_g + step * 0.5: + ticks.append({"z": round(z, 2), "py": round(z_to_y(z), 1)}) + z += step + + return real, ticks + + +def _extract_cross_sections(pdk, layer_stack): + xs_dict = getattr(pdk, "cross_sections", {}) + if not xs_dict: + return [] + + layer_z_by_gds = {} + layer_z_by_name = {} + for name, level in layer_stack.layers.items(): + thickness = getattr(level, "thickness", 0) or 0 + zmin = getattr(level, "zmin", 0) or 0 + material = getattr(level, "material", "") or "" + gds = _gds_layer_number(getattr(level, "layer", None)) + info = {"name": name, "zmin": zmin, "thickness": thickness, "material": material, "gds": gds} + if gds is not None: + layer_z_by_gds[gds] = info + layer_z_by_name[name] = info + + layer_name_to_gds = {} + layer_map = getattr(pdk, "layers", None) + if layer_map is not None: + try: + for entry in layer_map: + lname = getattr(entry, "name", None) + val = getattr(entry, "value", None) + gds = _gds_layer_number(val) if isinstance(val, (tuple, list)) else (val if isinstance(val, int) else _gds_layer_number(val)) + if lname and gds is not None: + layer_name_to_gds[lname] = gds + except TypeError: + pass + + def resolve(layer_ref): + gds = _gds_layer_number(layer_ref) + if gds is not None and gds in layer_z_by_gds: + return layer_z_by_gds[gds] + if isinstance(layer_ref, str): + if layer_ref in layer_z_by_name: + return layer_z_by_name[layer_ref] + g = layer_name_to_gds.get(layer_ref) + if g is not None and g in layer_z_by_gds: + return layer_z_by_gds[g] + return None + + results = [] + for xs_name, xs_func in xs_dict.items(): + try: + xs = xs_func() if callable(xs_func) else xs_func + except Exception: + continue + sections = getattr(xs, "sections", []) + main_layer = getattr(xs, "layer", None) + main_width = getattr(xs, "width", None) + xs_layers = [] + + if main_layer is not None and main_width is not None: + info = resolve(main_layer) + if info: + xs_layers.append({ + "name": info["name"], "material": info["material"], + "zmin": round(info["zmin"], 4), + "zmax": round(info["zmin"] + info["thickness"], 4), + "thickness": round(info["thickness"], 4), + "width": round(float(main_width), 2), "gds": info["gds"], + }) + + for section in sections: + sec_layer = getattr(section, "layer", None) + sec_width = getattr(section, "width", None) + if sec_layer is None or sec_width is None: + continue + info = resolve(sec_layer) + if info: + xs_layers.append({ + "name": info["name"], "material": info["material"], + "zmin": round(info["zmin"], 4), + "zmax": round(info["zmin"] + info["thickness"], 4), + "thickness": round(info["thickness"], 4), + "width": round(float(sec_width), 2), "gds": info["gds"], + }) + + if xs_layers: + results.append({"name": xs_name, "layers": xs_layers}) + + return results + + +# --------------------------------------------------------------------------- +# SVG rendering +# --------------------------------------------------------------------------- + +_svg_counter = 100 + + +def _next_id(): + global _svg_counter + _svg_counter += 1 + return f"sv_{_svg_counter}" + + +def _render_layer_stack(layers, ticks, svg_id, svg_w=550, svg_h=750): + margin = {"top": 60, "bottom": 30, "left": 70, "right": 100} + plot_w = svg_w - margin["left"] - margin["right"] + plot_h = svg_h - margin["top"] - margin["bottom"] + d = json.dumps(layers, separators=(",", ": ")) + t = json.dumps(ticks, separators=(",", ": ")) + m = json.dumps(margin, separators=(",", ": ")) + + return f'''
+
+ + +
+ + + Layer Stack + + + +
+''' + + +def _render_cross_sections(cross_sections, layer_stack, svg_id, svg_w=1000, svg_h=550): + if not cross_sections: + return "" + margin_l, margin_r, margin_t, margin_b = 60, 20, 40, 70 + plot_w = svg_w - margin_l - margin_r + plot_h = svg_h - margin_t - margin_b + + box_t = getattr(layer_stack, "box_thickness", 2.0) or 2.0 + all_z = [-box_t, 0.0] + clad_top = 0.0 + for name, level in layer_stack.layers.items(): + zmin = getattr(level, "zmin", 0) or 0 + thickness = getattr(level, "thickness", 0) or 0 + all_z.extend([zmin, zmin + thickness]) + if "clad" in name.lower(): + clad_top = max(clad_top, zmin + thickness) + for xs in cross_sections: + for layer in xs["layers"]: + all_z.extend([layer["zmin"], layer["zmax"]]) + + z_min_g = min(all_z) + z_max_g = max(all_z) + z_range = (z_max_g - z_min_g) or 1.0 + + def z2y(z): + return margin_t + plot_h - (z - z_min_g) / z_range * plot_h + + n_xs = len(cross_sections) + col_w = plot_w / max(n_xs, 1) + + parts = [] + parts.append(f'') + parts.append(f'') + parts.append(f'PDK Cross-Sections') + + step = _nice_step(z_range, 8) + z = math.floor(z_min_g / step) * step + while z <= z_max_g + step * 0.5: + y = z2y(z) + parts.append(f'') + parts.append(f'{z:.2f}') + z += step + + all_data = [] + idx = 0 + for i, xs in enumerate(cross_sections): + cx = margin_l + i * col_w + 0.7 + cxe = cx + col_w - 1.4 + inner_w = col_w - 2 * 6.0 + center = cx + (col_w - 1.4) / 2 + + # Cladding background + cy_top = z2y(clad_top) if clad_top > 0 else z2y(z_max_g) + cy_bot = z2y(0) + parts.append(f'') + all_data.append({"name": "Cladding", "material": "SiO2", "zmin": 0, "zmax": round(clad_top, 3), "thickness": round(clad_top, 3), "gds": None}) + idx += 1 + + # Substrate background + sy_top = z2y(0) + sy_bot = z2y(-box_t) + parts.append(f'') + all_data.append({"name": "Substrate", "material": "Si", "zmin": round(-box_t, 3), "zmax": 0, "thickness": round(box_t, 3), "gds": None}) + idx += 1 + + # Column borders + bt = min(cy_top, margin_t) + bb = max(sy_bot, margin_t + plot_h) + parts.append(f'') + parts.append(f'') + + # Layers + for layer in xs["layers"]: + yt = z2y(layer["zmax"]) + yb = z2y(layer["zmin"]) + h = max(yb - yt, 2) + wf = min(layer["width"] / 20.0, 1.0) + rw = inner_w * wf + rx = center - rw / 2 + color = _color_for_xs(layer["material"]) + parts.append(f'') + all_data.append(layer) + idx += 1 + + # Label + ly = margin_t + plot_h + 15 + parts.append(f'{xs["name"]}') + + parts.append(f'Z (µm)') + parts.append('') + svg_html = "\n".join(parts) + dj = json.dumps(all_data, separators=(",", ": ")) + + return f'''
+ {svg_html} + +
+''' + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +CSS = '''''' + + +def main(): + filepath = PATH.repo / "docs" / "layer_stack.md" + filepath.parent.mkdir(parents=True, exist_ok=True) + + parts = ["# Layer Stack\n"] + parts.append("Interactive layer stack and cross-section visualizations. " + "Hover for details, Ctrl+scroll to zoom, drag to pan, double-click to reset.\n") + + for band_label, pdk in BANDS: + pdk.activate() + ls = getattr(pdk, "layer_stack", None) + if ls is None: + print(f" Skipping {band_label}: no layer_stack") + continue + + parts.append(f"\n## {band_label}\n") + parts.append(CSS) + parts.append('\n
') + + layers = _extract_layers(ls) + layers, ticks = _compute_layout(layers) + if layers: + sid = _next_id() + parts.append('
') + parts.append('

Layer Stack

') + parts.append(f'
{_render_layer_stack(layers, ticks, sid)}
') + parts.append('
') + + xs = _extract_cross_sections(pdk, ls) + if xs: + sid = _next_id() + parts.append('
') + parts.append('

Cross-Sections

') + parts.append(f'
{_render_cross_sections(xs, ls, sid)}
') + parts.append('
') + + parts.append('
') + + content = "\n".join(parts) + "\n" + filepath.write_text(content) + print(f"Wrote {filepath}") + + +if __name__ == "__main__": + main() diff --git a/Makefile b/Makefile index d894eaf..c83c10b 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,7 @@ docs-pdf: uv run python .github/write_cells_si220_oband.py uv run python .github/write_cells_si500.py uv run python .github/write_cells_sin300.py + uv run python .github/write_layer_stack.py cp CHANGELOG.md docs/changelog.md cp README.md docs/index.md uv run mkdocs build -f mkdocs-pdf.yml @@ -69,6 +70,7 @@ docs: uv run python .github/write_cells_si220_oband.py uv run python .github/write_cells_si500.py uv run python .github/write_cells_sin300.py + uv run python .github/write_layer_stack.py cp CHANGELOG.md docs/changelog.md cp README.md docs/index.md uv run --extra docs zensical build -f docs/zensical.toml @@ -78,6 +80,7 @@ docs-serve: uv run python .github/write_cells_si220_oband.py uv run python .github/write_cells_si500.py uv run python .github/write_cells_sin300.py + uv run python .github/write_layer_stack.py cp CHANGELOG.md docs/changelog.md cp README.md docs/index.md uv run --extra docs zensical serve -f docs/zensical.toml -a localhost:8080 diff --git a/docs/zensical.toml b/docs/zensical.toml index 489605f..d771b05 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -3,6 +3,7 @@ docs_dir = "." nav = [ {"Home" = "index.md"}, {"Reference" = [ + {"Layer Stack" = "layer_stack.md"}, {"Cells Si220 Cband" = "cells_si220_cband.md"}, {"Cells Si220 Oband" = "cells_si220_oband.md"}, {"Cells Si500" = "cells_si500.md"},