Skip to content

Commit 890de07

Browse files
authored
Merge branch 'main' into emode_plugin
2 parents 4096f54 + 4d747cc commit 890de07

9 files changed

Lines changed: 2922 additions & 2354 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @joamatab @MatthewMckee4

.github/workflows/test_code.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
fail-fast: false
2121
max-parallel: 12
2222
matrix:
23-
plugin: [femwell, gmsh, meow, sax, tidy3d, klayout, vlsir]
23+
plugin: [femwell, gmsh, meow, sax, tidy3d, klayout, vlsir, path_length_analysis]
2424
os: [ubuntu-latest]
2525
python-version: [3.12]
2626
exclude:

.pre-commit-config.yaml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ repos:
2424
- id: ruff-format
2525
files: ^gplugins/(femwell|gmsh|meow|sax|tidy3d|klayout|vlsir)/
2626

27-
- repo: https://github.com/pre-commit/mirrors-mypy
28-
rev: "v1.16.0"
29-
hooks:
30-
- id: mypy
31-
args: [--ignore-missing-imports, --strict, --config-file=pyproject.toml]
32-
additional_dependencies:
33-
- gdsfactory
34-
- pytest
35-
files: ^gplugins/(femwell|gmsh|meow|sax|tidy3d|klayout|vlsir)/
27+
# - repo: https://github.com/pre-commit/mirrors-mypy
28+
# rev: "v1.16.0"
29+
# hooks:
30+
# - id: mypy
31+
# args: [--ignore-missing-imports, --strict, --config-file=pyproject.toml]
32+
# additional_dependencies:
33+
# - gdsfactory
34+
# - pytest
35+
# files: ^gplugins/(femwell|gmsh|meow|sax|tidy3d|klayout|vlsir)/
3636

3737
# - repo: https://github.com/shellcheck-py/shellcheck-py
3838
# rev: v0.9.0.5

gplugins/path_length_analysis/path_length_analysis_from_gds.py

Lines changed: 149 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# type: ignore
22
from collections.abc import Callable
3-
from functools import partial
43

54
import gdsfactory as gf
65
import kfactory as kf
@@ -10,10 +9,14 @@
109
import shapely.ops as ops
1110
from gdsfactory import logger
1211
from klayout.db import DPoint, Polygon
13-
from scipy.signal import savgol_filter
14-
from scipy.spatial import distance
12+
from scipy.spatial import Voronoi, distance, voronoi_plot_2d
13+
14+
from gplugins.path_length_analysis.utils import (
15+
filter_points_by_std_distance,
16+
resample_polygon_points_w_interpolator,
17+
sort_points_nearest_neighbor,
18+
)
1519

16-
filter_savgol_filter = partial(savgol_filter, window_length=11, polyorder=3, axis=0)
1720
fix_values = [0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6, 7, -7, 8, -8]
1821

1922

@@ -55,6 +58,118 @@ def _check_midpoint_found(inner_points, outer_points, port_list) -> bool:
5558
return False
5659

5760

61+
def centerline_voronoi_2_ports(
62+
poly: Polygon,
63+
port_list: list[gf.Port],
64+
plot: bool = False,
65+
) -> np.ndarray:
66+
"""Returns the centerline for a single polygon that has 2 ports.
67+
68+
This function uses Voronoi tessellation to find the centerline of a polygon.
69+
70+
Args:
71+
poly: The KLayout Polygon object representing the waveguide or structure.
72+
port_list: List of two ports (gf.Port objects) corresponding to the start and end ports of the polygon.
73+
plot: If True, plots the Voronoi diagram and the centerline.
74+
75+
Returns:
76+
np.ndarray: An array of (x, y) points representing the calculated centerline in microns.
77+
"""
78+
if len(port_list) != 2:
79+
raise ValueError(
80+
"port_list must be a list of 2 ports, got: "
81+
f"{port_list} with length {len(port_list)}"
82+
)
83+
# Simplify points that are too close to each other
84+
r = gf.kdb.Region(poly)
85+
r = r.smoothed(0.05, True)
86+
87+
# Get polygon points from klayout DPolygon and resample
88+
points = np.array([(pt.x, pt.y) for pt in r[0].each_point_hull()])
89+
if len(points) < 3:
90+
raise ValueError(
91+
"The polygon must have at least 3 points to compute a centerline."
92+
)
93+
# Ensure the polygon is closed by appending the first point to the end
94+
# This ensures resampling for all edges later on
95+
points = np.vstack((points, points[0]))
96+
shapely_poly_original = sh.Polygon(points)
97+
98+
# Infer port widths to be 2x the distance between the port center and the nearest point on the polygon
99+
port_widths = []
100+
for port in port_list:
101+
# Compute distances from each point to port center
102+
distances = distance.cdist([port.center], points)
103+
# Find the minimum distance
104+
min_distance = np.min(distances)
105+
port_widths.append(min_distance * 2)
106+
107+
# Interpolate between the points to ensure enough sampling using Pchip interpolation
108+
interpolated_points = resample_polygon_points_w_interpolator(
109+
points, n_samples_coeff=2
110+
)
111+
112+
# Remove interpolated points that are too close to the ports
113+
for port, width in zip(port_list, port_widths):
114+
distances = distance.cdist([port.center], interpolated_points)
115+
# Keep points that are at least half a width away from the port center
116+
mask = distances[0] > (width - 1) / 2
117+
interpolated_points = interpolated_points[mask]
118+
119+
# Use Voronoi to find the centerline
120+
voronoi = Voronoi(interpolated_points)
121+
122+
if plot:
123+
voronoi_plot_2d(voronoi)
124+
plt.gca().set_aspect("equal", adjustable="box") # Force equilateral axes
125+
plt.show()
126+
127+
# Filter Voronoi vertices to keep only those inside the original polygon
128+
centerline = np.array(
129+
[v for v in voronoi.vertices if shapely_poly_original.contains(sh.Point(v))]
130+
)
131+
132+
# Add ports as start and end points
133+
centerline = np.vstack((port_list[0].center, centerline, port_list[1].center))
134+
135+
# Consider points that are 3× the standard deviation away from the mean distance to be artifacts
136+
centerline = sort_points_nearest_neighbor(centerline, start_idx=0)
137+
centerline = filter_points_by_std_distance(centerline)
138+
139+
# The points are not guaranteed to be ordered, so we need to sort them
140+
# Initially sort the centerline by euclidean distance from (0, 0)
141+
# centerline = centerline[np.argsort(np.linalg.norm(centerline, axis=1))]
142+
centerline = sort_points_nearest_neighbor(centerline, start_idx=0)
143+
144+
# Because of Voronoi tessellation zig-zagging on less sampled polygons,
145+
# take only half the points by averaging consecutive pairs
146+
if len(centerline) % 2 != 0:
147+
centerline = centerline[:-1] # Ensure even number of points
148+
centerline = (centerline[::2] + centerline[1::2]) / 2
149+
150+
# Filter again just in case
151+
centerline = np.array(
152+
[v for v in centerline if shapely_poly_original.contains(sh.Point(v))]
153+
)
154+
155+
# Re-add ports as start and end points
156+
centerline = np.vstack((port_list[0].center, centerline, port_list[1].center))
157+
centerline = sort_points_nearest_neighbor(centerline)
158+
159+
# Resample the centerline to have a specific resolution for more robust curvature post-processing
160+
# Use a fraction of the original number of samples to avoid oversampling
161+
centerline = resample_polygon_points_w_interpolator(
162+
centerline, n_samples_coeff=1 / 3
163+
)
164+
# This is inefficient but works for now
165+
centerline = sort_points_nearest_neighbor(centerline)
166+
167+
# Convert to microns
168+
centerline *= 1e-3
169+
170+
return centerline
171+
172+
58173
def centerline_single_poly_2_ports(poly, under_sampling, port_list) -> np.ndarray:
59174
"""Returns the centerline for a single polygon that has 2 ports.
60175
@@ -252,6 +367,7 @@ def extract_paths(
252367
evanescent_coupling: bool = False,
253368
consider_ports: list[str] | None = None,
254369
port_positions: list[tuple[float, float]] | None = None,
370+
**kwargs,
255371
) -> dict:
256372
"""Extracts the centerline of a component or instance from a GDS file.
257373
@@ -262,7 +378,7 @@ def extract_paths(
262378
component: gdsfactory component or instance to extract from.
263379
layer: layer to extract the centerline from.
264380
plot: If True, we plot the extracted paths.
265-
filter_function: optional Function to filter the centerline.
381+
filter_function: optional function to filter the centerline.
266382
under_sampling: under sampling factor of the polygon points.
267383
evanescent_coupling: if True, it assumes that there is evanescent coupling
268384
between ports not physically connected.
@@ -275,6 +391,7 @@ def extract_paths(
275391
Note - this will not work in cases where the specified port positions are only coupled
276392
evanescently. A workaround for this limitation is to specify one additional port that is
277393
physically connected to the ports of interest, for each port of interest.
394+
**kwargs: Extra keyword arguments to pass to the centerline extraction function.
278395
"""
279396
ev_paths = None
280397

@@ -330,13 +447,14 @@ def extract_paths(
330447
if poly[0].is_box(): # only 4 points, no undersampling
331448
centerline = centerline_single_poly_2_ports(poly, 1, ports_list)
332449
else:
333-
centerline = centerline_single_poly_2_ports(
334-
poly, under_sampling, ports_list
450+
centerline = centerline_voronoi_2_ports(
451+
poly,
452+
ports_list,
453+
**kwargs,
335454
)
336455
if filter_function is not None:
337456
centerline = filter_function(centerline)
338-
p = gf.Path(centerline)
339-
paths[f"{ports_list[0].name};{ports_list[1].name}"] = p
457+
paths[f"{ports_list[0].name};{ports_list[1].name}"] = gf.Path(centerline)
340458

341459
else:
342460
# Single polygon and more than 2 ports - MMI
@@ -387,13 +505,16 @@ def extract_paths(
387505
]
388506
if len(ports_poly) == 2:
389507
# Each polygon has two ports - simple case
390-
centerline = centerline_single_poly_2_ports(
391-
poly, under_sampling, ports_poly
508+
centerline = centerline_voronoi_2_ports(
509+
poly,
510+
ports_poly,
511+
**kwargs,
392512
)
393513
if filter_function is not None:
394514
centerline = filter_function(centerline)
395-
p = gf.Path(centerline)
396-
paths[f"{ports_poly[0].name};{ports_poly[1].name}"] = p
515+
paths[f"{ports_poly[0].name};{ports_poly[1].name}"] = gf.Path(
516+
centerline
517+
)
397518

398519
elif len(ports_poly) == 0:
399520
# No ports in the polygon - continue
@@ -423,11 +544,9 @@ def extract_paths(
423544
if port1 == port2:
424545
# Same port - skip
425546
continue
426-
if (
427-
f"{port1};{port2}" in paths
428-
or f"{port2};{port1}" in paths
429-
or f"{port1};{port2}" in ev_paths
430-
or f"{port2};{port1}" in ev_paths
547+
if any(
548+
s in paths | ev_paths
549+
for s in (f"{port1};{port2}", f"{port2};{port1}")
431550
):
432551
# The path has already been computed
433552
continue
@@ -462,8 +581,8 @@ def extract_paths(
462581
# Find the point closest to the center of the polygon
463582
center = np.array(
464583
[
465-
simplified_component.dcenter.x,
466-
simplified_component.dcenter.y,
584+
simplified_component.dcenter[0],
585+
simplified_component.dcenter[1],
467586
]
468587
)
469588

@@ -535,14 +654,15 @@ def extract_paths(
535654
plt.plot(
536655
centerline.points[:, 0],
537656
centerline.points[:, 1],
538-
"--",
657+
"x--",
539658
label=ports,
540659
)
541660
plt.legend()
542661
plt.title("Direct paths")
543662
plt.xlabel("X-coordinate")
544663
plt.ylabel("Y-coordinate")
545664
plt.grid(True)
665+
plt.gca().set_aspect("equal", adjustable="box") # Force equilateral axes
546666

547667
if ev_paths is not None:
548668
plt.figure()
@@ -580,6 +700,9 @@ def get_min_radius_and_length_path_dict(path_dict: dict) -> dict:
580700
def get_min_radius_and_length(path: gf.Path) -> tuple[float, float]:
581701
"""Get the minimum radius of curvature and the length of a path."""
582702
_, K = path.curvature()
703+
# Ignore the end points if possible as these may have artifacts
704+
if len(K) > 3:
705+
K = K[1:-1]
583706
radius = 1 / K
584707
min_radius = np.nanmin(np.abs(radius))
585708
return min_radius, path.length()
@@ -595,14 +718,15 @@ def plot_curvature(path: gf.Path, rmax: int | float = 200) -> None:
595718
s, K = path.curvature()
596719
radius = 1 / K
597720
valid_indices = (radius > -rmax) & (radius < rmax)
598-
radius2 = radius[valid_indices]
721+
curvature2 = K[valid_indices]
599722
s2 = s[valid_indices]
600723

601724
plt.figure(figsize=(10, 5))
602-
plt.plot(s2, radius2, ".-")
725+
plt.plot(s2, curvature2, ".-")
603726
plt.xlabel("Position along curve (arc length)")
604-
plt.ylabel("Radius of curvature")
727+
plt.ylabel("Curvature (units⁻¹)")
605728
plt.show()
729+
return plt
606730

607731

608732
def plot_radius(path: gf.Path, rmax: float = 200) -> plt.Figure:
@@ -621,7 +745,7 @@ def plot_radius(path: gf.Path, rmax: float = 200) -> plt.Figure:
621745
fig, ax = plt.subplots(1, 1, figsize=(15, 5))
622746
ax.plot(s2, radius2, ".-")
623747
ax.set_xlabel("Position along curve (arc length)")
624-
ax.set_ylabel("Radius of curvature")
748+
ax.set_ylabel("Radius of curvature (units)")
625749
ax.grid(True)
626750
return fig
627751

gplugins/path_length_analysis/test_pathlength_extraction.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def test_primitives_have_route_info(component_name) -> None:
2525
assert c.info["route_info_length"] == c.info["length"]
2626

2727

28+
# TODO: This test is currently skipped because the `report_pathlengths` function
29+
@pytest.mark.skip(
30+
reason="report_pathlengths is not update to work with recent gdsfactory"
31+
)
2832
@pytest.mark.parametrize("cross_section", supported_cross_sections)
2933
def test_pathlength_extraction(cross_section) -> None:
3034
component_name = f"{test_pathlength_extraction.__name__}_{cross_section}"
@@ -55,6 +59,10 @@ def test_pathlength_extraction(cross_section) -> None:
5559
assert {src_port, dst_port} == {"o1", "o2"}
5660

5761

62+
# TODO: This test is currently skipped because the `report_pathlengths` function
63+
@pytest.mark.skip(
64+
reason="report_pathlengths is not update to work with recent gdsfactory"
65+
)
5866
def test_multi_path_extraction() -> None:
5967
component_name = f"{test_multi_path_extraction.__name__}"
6068
c = gf.Component(component_name)
@@ -103,6 +111,9 @@ def pathlength_test_subckt(lengths, cross_section: str = "strip"):
103111
return c1
104112

105113

114+
@pytest.mark.skip(
115+
reason="report_pathlengths is not update to work with recent gdsfactory"
116+
)
106117
def test_hierarchical_pathlength_extraction() -> None:
107118
component_name = f"{test_hierarchical_pathlength_extraction.__name__}"
108119
cross_section = "strip"
@@ -135,6 +146,10 @@ def test_hierarchical_pathlength_extraction() -> None:
135146
assert {src_port, dst_port} == {"o1", "o2"}
136147

137148

149+
# TODO: This test is currently skipped because the `report_pathlengths` function
150+
@pytest.mark.skip(
151+
reason="report_pathlengths is not update to work with recent gdsfactory"
152+
)
138153
def test_transformed_hierarchical_pathlength_extraction() -> None:
139154
component_name = f"{test_transformed_hierarchical_pathlength_extraction.__name__}"
140155
cross_section = "strip"

0 commit comments

Comments
 (0)