|
8 | 8 | from tidy3d.exceptions import Tidy3dKeyError
|
9 | 9 |
|
10 | 10 |
|
| 11 | +def _create_unit_aware_locator(): |
| 12 | + """Create UnitAwareLocator lazily due to matplotlib import restrictions.""" |
| 13 | + import matplotlib.ticker as ticker |
| 14 | + |
| 15 | + class UnitAwareLocator(ticker.Locator): |
| 16 | + """Custom tick locator that places ticks at nice positions in the target unit.""" |
| 17 | + |
| 18 | + def __init__(self, scale_factor: float): |
| 19 | + """ |
| 20 | + Parameters |
| 21 | + ---------- |
| 22 | + scale_factor : float |
| 23 | + Factor to convert from micrometers to the target unit. |
| 24 | + """ |
| 25 | + super().__init__() |
| 26 | + self.scale_factor = scale_factor |
| 27 | + |
| 28 | + def __call__(self): |
| 29 | + vmin, vmax = self.axis.get_view_interval() |
| 30 | + return self.tick_values(vmin, vmax) |
| 31 | + |
| 32 | + def view_limits(self, vmin, vmax): |
| 33 | + """Override to prevent matplotlib from adjusting our limits.""" |
| 34 | + return vmin, vmax |
| 35 | + |
| 36 | + def tick_values(self, vmin, vmax): |
| 37 | + # convert the view range to the target unit |
| 38 | + vmin_unit = vmin * self.scale_factor |
| 39 | + vmax_unit = vmax * self.scale_factor |
| 40 | + |
| 41 | + # tolerance for floating point comparisons in target unit |
| 42 | + unit_range = vmax_unit - vmin_unit |
| 43 | + unit_tol = unit_range * 1e-8 |
| 44 | + |
| 45 | + locator = ticker.MaxNLocator(nbins=11, prune=None, min_n_ticks=2) |
| 46 | + |
| 47 | + ticks_unit = locator.tick_values(vmin_unit, vmax_unit) |
| 48 | + |
| 49 | + # ensure we have ticks that cover the full range |
| 50 | + if len(ticks_unit) > 0: |
| 51 | + if ticks_unit[0] > vmin_unit + unit_tol or ticks_unit[-1] < vmax_unit - unit_tol: |
| 52 | + # try with fewer bins to get better coverage |
| 53 | + for n in [10, 9, 8, 7, 6, 5]: |
| 54 | + locator = ticker.MaxNLocator(nbins=n, prune=None, min_n_ticks=2) |
| 55 | + ticks_unit = locator.tick_values(vmin_unit, vmax_unit) |
| 56 | + if ( |
| 57 | + len(ticks_unit) >= 3 |
| 58 | + and ticks_unit[0] <= vmin_unit + unit_tol |
| 59 | + and ticks_unit[-1] >= vmax_unit - unit_tol |
| 60 | + ): |
| 61 | + break |
| 62 | + |
| 63 | + # if still no good coverage, manually ensure edge coverage |
| 64 | + if len(ticks_unit) > 0: |
| 65 | + if ( |
| 66 | + ticks_unit[0] > vmin_unit + unit_tol |
| 67 | + or ticks_unit[-1] < vmax_unit - unit_tol |
| 68 | + ): |
| 69 | + # find a reasonable step size from existing ticks |
| 70 | + if len(ticks_unit) > 1: |
| 71 | + step = ticks_unit[1] - ticks_unit[0] |
| 72 | + else: |
| 73 | + step = unit_range / 5 |
| 74 | + |
| 75 | + # extend the range to ensure coverage |
| 76 | + extended_min = vmin_unit - step |
| 77 | + extended_max = vmax_unit + step |
| 78 | + |
| 79 | + # try one more time with extended range |
| 80 | + locator = ticker.MaxNLocator(nbins=8, prune=None, min_n_ticks=2) |
| 81 | + ticks_unit = locator.tick_values(extended_min, extended_max) |
| 82 | + |
| 83 | + # filter to reasonable bounds around the original range |
| 84 | + ticks_unit = [ |
| 85 | + t |
| 86 | + for t in ticks_unit |
| 87 | + if t >= vmin_unit - step / 2 and t <= vmax_unit + step / 2 |
| 88 | + ] |
| 89 | + |
| 90 | + # convert the nice ticks back to the original data unit (micrometers) |
| 91 | + ticks_um = ticks_unit / self.scale_factor |
| 92 | + |
| 93 | + # filter to ensure ticks are within bounds (with small tolerance) |
| 94 | + eps = (vmax - vmin) * 1e-8 |
| 95 | + return [tick for tick in ticks_um if vmin - eps <= tick <= vmax + eps] |
| 96 | + |
| 97 | + return UnitAwareLocator |
| 98 | + |
| 99 | + |
11 | 100 | def make_ax() -> Ax:
|
12 | 101 | """makes an empty ``ax``."""
|
13 | 102 | import matplotlib.pyplot as plt
|
@@ -72,14 +161,24 @@ def set_default_labels_and_title(
|
72 | 161 | )
|
73 | 162 | ax.set_xlabel(f"{xlabel} ({plot_length_units})")
|
74 | 163 | ax.set_ylabel(f"{ylabel} ({plot_length_units})")
|
75 |
| - # Formatter to help plot in arbitrary units |
| 164 | + |
76 | 165 | scale_factor = UnitScaling[plot_length_units]
|
| 166 | + |
| 167 | + # for imperial units, use custom tick locator for nice tick positions |
| 168 | + if plot_length_units in ["mil", "in"]: |
| 169 | + UnitAwareLocator = _create_unit_aware_locator() |
| 170 | + x_locator = UnitAwareLocator(scale_factor) |
| 171 | + y_locator = UnitAwareLocator(scale_factor) |
| 172 | + ax.xaxis.set_major_locator(x_locator) |
| 173 | + ax.yaxis.set_major_locator(y_locator) |
| 174 | + |
77 | 175 | formatter = ticker.FuncFormatter(lambda y, _: f"{y * scale_factor:.2f}")
|
| 176 | + |
78 | 177 | ax.xaxis.set_major_formatter(formatter)
|
79 | 178 | ax.yaxis.set_major_formatter(formatter)
|
80 |
| - ax.set_title( |
81 |
| - f"cross section at {'xyz'[axis]}={position * scale_factor:.2f} ({plot_length_units})" |
82 |
| - ) |
| 179 | + |
| 180 | + position_scaled = position * scale_factor |
| 181 | + ax.set_title(f"cross section at {'xyz'[axis]}={position_scaled:.2f} ({plot_length_units})") |
83 | 182 | else:
|
84 | 183 | ax.set_xlabel(xlabel)
|
85 | 184 | ax.set_ylabel(ylabel)
|
|
0 commit comments