Skip to content

Commit d20adf8

Browse files
flaportclaude
andauthored
Support hierarchical probes with dot-separated paths (#91)
Probes can now target instance ports inside sub-circuits using dot-separated paths like "sub1.wg1,out0". The probe is inserted at the target component level and its _fwd/_bwd ports are automatically exposed through every intermediate level of the hierarchy. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fa2a1f6 commit d20adf8

3 files changed

Lines changed: 455 additions & 13 deletions

File tree

nbs/examples/14_probes.ipynb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,94 @@
429429
"plt.grid(True, alpha=0.3)\n",
430430
"plt.show()"
431431
]
432+
},
433+
{
434+
"cell_type": "markdown",
435+
"id": "27",
436+
"metadata": {},
437+
"source": [
438+
"## Hierarchical Probes\n",
439+
"\n",
440+
"Probes can also target instance ports inside sub-circuits using dot-separated paths. For example, `\"mzi.top,in0\"` probes port `in0` of instance `top` inside the component used by instance `mzi`.\n",
441+
"\n",
442+
"The probe ports (`_fwd` and `_bwd`) are automatically exposed through every level of the hierarchy."
443+
]
444+
},
445+
{
446+
"cell_type": "code",
447+
"execution_count": null,
448+
"id": "28",
449+
"metadata": {},
450+
"outputs": [],
451+
"source": [
452+
"hierarchical_netlist = {\n",
453+
" \"top_level\": {\n",
454+
" \"instances\": {\n",
455+
" \"mzi\": \"mzi_component\",\n",
456+
" },\n",
457+
" \"connections\": {},\n",
458+
" \"ports\": {\n",
459+
" \"in0\": \"mzi,in0\",\n",
460+
" \"in1\": \"mzi,in1\",\n",
461+
" \"out0\": \"mzi,out0\",\n",
462+
" \"out1\": \"mzi,out1\",\n",
463+
" },\n",
464+
" },\n",
465+
" \"mzi_component\": {\n",
466+
" \"instances\": {\n",
467+
" \"lft\": \"coupler\",\n",
468+
" \"top\": \"waveguide\",\n",
469+
" \"btm\": \"waveguide\",\n",
470+
" \"rgt\": \"coupler\",\n",
471+
" },\n",
472+
" \"connections\": {\n",
473+
" \"lft,out0\": \"btm,in0\",\n",
474+
" \"btm,out0\": \"rgt,in0\",\n",
475+
" \"lft,out1\": \"top,in0\",\n",
476+
" \"top,out0\": \"rgt,in1\",\n",
477+
" },\n",
478+
" \"ports\": {\n",
479+
" \"in0\": \"lft,in0\",\n",
480+
" \"in1\": \"lft,in1\",\n",
481+
" \"out0\": \"rgt,out0\",\n",
482+
" \"out1\": \"rgt,out1\",\n",
483+
" },\n",
484+
" },\n",
485+
"}\n",
486+
"\n",
487+
"mzi_hier, _ = sax.circuit(\n",
488+
" hierarchical_netlist,\n",
489+
" models,\n",
490+
" probes={\n",
491+
" \"top_arm\": \"mzi.top,in0\",\n",
492+
" \"btm_arm\": \"mzi.btm,in0\",\n",
493+
" },\n",
494+
")\n",
495+
"\n",
496+
"S = mzi_hier(wl=wl, mzi={\"top\": {\"length\": 25.0}, \"btm\": {\"length\": 15.0}})\n",
497+
"print(\"Circuit ports:\", sax.get_ports(S))"
498+
]
499+
},
500+
{
501+
"cell_type": "code",
502+
"execution_count": null,
503+
"id": "29",
504+
"metadata": {},
505+
"outputs": [],
506+
"source": [
507+
"plt.figure(figsize=(10, 4))\n",
508+
"plt.plot(wl * 1e3, jnp.abs(S[\"in0\", \"out0\"]) ** 2, label=\"in0 → out0\")\n",
509+
"plt.plot(wl * 1e3, jnp.abs(S[\"in0\", \"out1\"]) ** 2, label=\"in0 → out1\")\n",
510+
"plt.plot(wl * 1e3, jnp.abs(S[\"in0\", \"top_arm_fwd\"]) ** 2, label=\"in0 → top arm (fwd)\")\n",
511+
"plt.plot(wl * 1e3, jnp.abs(S[\"in0\", \"btm_arm_fwd\"]) ** 2, label=\"in0 → btm arm (fwd)\", ls=\"--\") # fmt: skip\n",
512+
"plt.xlabel(\"Wavelength [nm]\")\n",
513+
"plt.ylabel(\"Power\")\n",
514+
"plt.title(\"Hierarchical MZI with Probes\")\n",
515+
"plt.legend()\n",
516+
"plt.ylim(-0.05, 1.05)\n",
517+
"plt.grid(True, alpha=0.3)\n",
518+
"plt.show()"
519+
]
432520
}
433521
],
434522
"metadata": {

src/sax/netlists.py

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -439,21 +439,27 @@ def expand_probes( # noqa: PLR0915,C901
439439
points in the netlist. Each probe intercepts a connection and exposes forward
440440
and backward traveling wave ports.
441441
442+
Probes can target instance ports at any level of a recursive netlist using
443+
dot-separated paths. For example, ``"sub1.wg1,out0"`` targets port ``out0``
444+
of instance ``wg1`` inside the component used by instance ``sub1``.
445+
442446
Args:
443447
netlist: The netlist to expand probes in.
444448
probes: A mapping from probe names to instance ports where probes should
445-
be inserted. If the instance port is part of an existing connection,
446-
a 4-port probe is inserted. If the instance port is unconnected,
447-
only the "X_fwd" port is created as a direct alias.
448-
Example: ``{"mid": "wg1,out"}``
449+
be inserted. Instance ports can use dot-separated paths to target
450+
sub-circuits (e.g. ``"sub1.wg1,out0"``). If the instance port is
451+
part of an existing connection, a 4-port probe is inserted. If the
452+
instance port is unconnected, only the "X_fwd" port is created as a
453+
direct alias.
449454
450455
Returns:
451456
A new netlist with probe instances inserted and connections/ports updated.
452457
For probes on connected ports, two new ports are added: "X_fwd" and "X_bwd".
453458
For probes on unconnected ports, only "X_fwd" is added.
454459
455460
Raises:
456-
ValueError: If probe ports would conflict with existing ports.
461+
ValueError: If probe ports would conflict with existing ports, or if a
462+
hierarchical path references a nonexistent instance or component.
457463
458464
Example:
459465
```python
@@ -471,15 +477,9 @@ def expand_probes( # noqa: PLR0915,C901
471477
if not probes:
472478
return netlist
473479

474-
# Handle recursive netlist: only expand probes in top-level
480+
# Handle recursive netlist
475481
if (recnet := sax.try_into[sax.RecursiveNetlist](netlist)) is not None:
476-
top_level_name = next(iter(recnet))
477-
top_level = recnet[top_level_name]
478-
expanded_top = expand_probes(top_level, probes)
479-
return {
480-
top_level_name: expanded_top,
481-
**{k: v for k, v in recnet.items() if k != top_level_name},
482-
}
482+
return _expand_probes_recursive(recnet, probes)
483483

484484
# It's a flat netlist
485485
net: sax.Netlist = deepcopy(sax.into[sax.Netlist](netlist))
@@ -571,6 +571,88 @@ def expand_probes( # noqa: PLR0915,C901
571571
return net
572572

573573

574+
def _expand_probes_recursive( # noqa: C901
575+
netlist: sax.RecursiveNetlist,
576+
probes: dict[str, str],
577+
) -> sax.RecursiveNetlist:
578+
"""Expand probes in a recursive netlist, supporting hierarchical paths."""
579+
recnet = deepcopy(netlist)
580+
top_level_name = next(iter(recnet))
581+
582+
# Separate top-level probes from hierarchical probes
583+
top_level_probes: dict[str, str] = {}
584+
# Group hierarchical probes by target component
585+
# Maps: target_component -> {probe_name: instance_port}
586+
component_probes: dict[str, dict[str, str]] = {}
587+
# Maps: probe_name -> [(instance_name, parent_component), ...]
588+
probe_paths: dict[str, list[tuple[str, str]]] = {}
589+
590+
for probe_name, instance_port in probes.items():
591+
parts = instance_port.split(".")
592+
if len(parts) == 1:
593+
top_level_probes[probe_name] = instance_port
594+
else:
595+
# Walk the hierarchy to find the target component
596+
current_component = top_level_name
597+
path: list[tuple[str, str]] = []
598+
for instance_name in parts[:-1]:
599+
current_netlist = recnet[current_component]
600+
instances = current_netlist.get("instances", {})
601+
if instance_name not in instances:
602+
msg = (
603+
f"Hierarchical probe '{probe_name}': instance "
604+
f"'{instance_name}' not found in component "
605+
f"'{current_component}'. "
606+
f"Available instances: {list(instances.keys())}"
607+
)
608+
raise ValueError(msg)
609+
inst = instances[instance_name]
610+
child_component = inst["component"] if isinstance(inst, dict) else inst
611+
if child_component not in recnet:
612+
msg = (
613+
f"Hierarchical probe '{probe_name}': component "
614+
f"'{child_component}' (used by instance "
615+
f"'{instance_name}' in '{current_component}') "
616+
f"is not defined in the recursive netlist. "
617+
f"Only sub-circuits (not primitives) can be probed."
618+
)
619+
raise ValueError(msg)
620+
path.append((instance_name, current_component))
621+
current_component = child_component
622+
623+
target_instance_port = parts[-1]
624+
if current_component not in component_probes:
625+
component_probes[current_component] = {}
626+
component_probes[current_component][probe_name] = target_instance_port
627+
probe_paths[probe_name] = path
628+
629+
# Expand top-level probes using existing flat-netlist logic
630+
if top_level_probes:
631+
recnet[top_level_name] = expand_probes(recnet[top_level_name], top_level_probes)
632+
633+
# Expand hierarchical probes at their target component level
634+
for component_name, comp_probes in component_probes.items():
635+
recnet[component_name] = expand_probes(recnet[component_name], comp_probes)
636+
637+
# Bubble up: expose probe ports through each intermediate level
638+
for probe_name, path in probe_paths.items():
639+
fwd_port = f"{probe_name}_fwd"
640+
bwd_port = f"{probe_name}_bwd"
641+
for instance_name, parent_component in reversed(path):
642+
parent_ports = recnet[parent_component].get("ports", {})
643+
if fwd_port in parent_ports or bwd_port in parent_ports:
644+
msg = (
645+
f"Hierarchical probe '{probe_name}' would create ports "
646+
f"'{fwd_port}'/'{bwd_port}' which conflict with existing "
647+
f"ports in component '{parent_component}'."
648+
)
649+
raise ValueError(msg)
650+
parent_ports[fwd_port] = f"{instance_name},{fwd_port}"
651+
parent_ports[bwd_port] = f"{instance_name},{bwd_port}"
652+
653+
return recnet
654+
655+
574656
@overload
575657
def extract_port_probes(
576658
netlist: sax.Netlist,

0 commit comments

Comments
 (0)