Source code for pysmithchart.grid

"""Grid drawing functionality using constant value plotting functions."""

import numpy as np

from pysmithchart.constants import SC_EPSILON, SC_NEAR_INFINITY
from pysmithchart.utils import choose_minor_divider


[docs] class GridMixin: """Mixin class providing grid drawing methods for SmithAxes."""
[docs] def grid(self, grid="impedance", **kwargs): """ Draw gridlines on the Smith chart. The grid is controlled by configuration parameters: - grid.Z.major.enable / grid.Z.minor.enable (impedance) - grid.Y.major.enable / grid.Y.minor.enable (admittance) - grid.fancy (enables adaptive clipping for both grids) Args: grid (str): 'impedance', 'admittance', or 'both' (default: 'both') **kwargs: Styling parameters that override configuration """ assert grid in ["impedance", "admittance", "both"] fancy = self._get_key("grid.fancy") draw_impedance = grid in ["impedance", "both"] draw_admittance = grid in ["admittance", "both"] # Draw impedance grids if draw_impedance: if self._get_key("grid.Z.major.enable"): self._draw_impedance_major(fancy, **kwargs) if self._get_key("grid.Z.minor.enable"): self._draw_impedance_minor(fancy, **kwargs) # Draw admittance grids if draw_admittance: if self._get_key("grid.Y.major.enable"): self._draw_admittance_major(fancy, **kwargs) if self._get_key("grid.Y.minor.enable"): self._draw_admittance_minor(fancy, **kwargs)
# ========== IMPEDANCE DRAWING ========== def _draw_impedance_major(self, fancy, **kwargs): """Draw major impedance gridlines. Args: fancy: If True, use adaptive clipping based on Möbius distance **kwargs: Style overrides """ style = self._get_grid_style("impedance", "major", **kwargs) style.setdefault("marker", "") # Get major ticks and ranges (domain-agnostic, returns ABSOLUTE values) xticks = np.sort(self.xaxis.get_majorticklocs()) yticks = np.sort(self.yaxis.get_majorticklocs()) threshold = self._get_key("grid.major.threshold") if fancy: r_ranges, x_ranges = self._compute_major_ranges(xticks, yticks, threshold) else: r_ranges = [None] * len(xticks) x_ranges = [None] * len(yticks) # Draw resistance circles with clipped reactance ranges for r, x_range in zip(xticks, r_ranges): if 0 <= r < SC_NEAR_INFINITY: self.plot_constant_resistance(r, range=x_range, **style) # Draw reactance circles with clipped resistance ranges for x, r_range in zip(yticks, x_ranges): if abs(x) < SC_NEAR_INFINITY: self.plot_constant_reactance(x, range=r_range, **style) def _draw_impedance_minor(self, fancy, **kwargs): """Draw minor impedance gridlines with nice spacing. Args: fancy: If True, use adaptive clipping based on Möbius distance **kwargs: Style overrides """ style = self._get_grid_style("impedance", "minor", **kwargs) style.setdefault("marker", "") # Get impedance minor grid parameters real_divs = self._get_key("grid.Z.minor.real.divisions") imag_divs = self._get_key("grid.Z.minor.imag.divisions") threshold = self._get_key("grid.minor.threshold") # Compute minor tick values (domain-agnostic, returns ABSOLUTE values) xt_major = np.sort(self.xaxis.get_majorticklocs()) yt_major = np.sort(self.yaxis.get_majorticklocs()) if len(xt_major) == 0 or len(yt_major) == 0: return x_minor, y_minor = self._compute_minor_ticks(xt_major, yt_major, real_divs, imag_divs, threshold) if fancy: # First compute major ranges major_r_ranges, major_x_ranges = self._compute_major_ranges( xt_major, yt_major, self._get_key("grid.major.threshold") ) # Then inherit those ranges for minor gridlines r_ranges, x_ranges = self._inherit_major_ranges( x_minor, y_minor, xt_major, yt_major, major_r_ranges, major_x_ranges ) else: r_ranges = [None] * len(x_minor) x_ranges = [None] * len(y_minor) # Draw minor gridlines with inherited clipping from major for r, x_range in zip(x_minor, r_ranges): if 0 <= r < SC_NEAR_INFINITY: self.plot_constant_resistance(r, range=x_range, **style) for x, r_range in zip(y_minor, x_ranges): if abs(x) < SC_NEAR_INFINITY: self.plot_constant_reactance(x, range=r_range, **style) # ========== ADMITTANCE DRAWING ========== def _draw_admittance_major(self, fancy, **kwargs): """Draw major admittance gridlines. Args: fancy: If True, use adaptive clipping based on Möbius distance **kwargs: Style overrides """ style = self._get_grid_style("admittance", "major", **kwargs) style.setdefault("marker", "") # Get major ticks and ranges (same tick computation, domain-agnostic) xticks = np.sort(self.xaxis.get_majorticklocs()) yticks = np.sort(self.yaxis.get_majorticklocs()) if fancy: threshold = self._get_key("grid.major.threshold") g_ranges, b_ranges = self._compute_major_ranges(xticks, yticks, threshold) else: g_ranges = [None] * len(xticks) b_ranges = [None] * len(yticks) # Draw conductance circles with clipped susceptance ranges for g, b_range in zip(xticks, g_ranges): if g > 1e-10: self.plot_constant_conductance(g, range=b_range, **style) # Draw susceptance circles with clipped conductance ranges for b, g_range in zip(yticks, b_ranges): self.plot_constant_susceptance(b, range=g_range, **style) def _draw_admittance_minor(self, fancy, **kwargs): """Draw minor admittance gridlines with nice spacing. Args: fancy: If True, use adaptive clipping based on Möbius distance **kwargs: Style overrides """ style = self._get_grid_style("admittance", "minor", **kwargs) style.setdefault("marker", "") # Get admittance minor grid parameters real_divs = self._get_key("grid.Y.minor.real.divisions") imag_divs = self._get_key("grid.Y.minor.imag.divisions") threshold = self._get_key("grid.minor.threshold") # Compute minor tick values (domain-agnostic, returns ABSOLUTE values) xt_major = np.sort(self.xaxis.get_majorticklocs()) yt_major = np.sort(self.yaxis.get_majorticklocs()) if len(xt_major) == 0 or len(yt_major) == 0: return g_minor, b_minor = self._compute_minor_ticks(xt_major, yt_major, real_divs, imag_divs, threshold) if fancy: # First compute major ranges major_g_ranges, major_b_ranges = self._compute_major_ranges( xt_major, yt_major, self._get_key("grid.major.threshold") ) # Then inherit those ranges for minor gridlines g_ranges, b_ranges = self._inherit_major_ranges( g_minor, b_minor, xt_major, yt_major, major_g_ranges, major_b_ranges ) else: g_ranges = [None] * len(g_minor) b_ranges = [None] * len(b_minor) # Draw minor gridlines with inherited clipping from major for g, b_range in zip(g_minor, g_ranges): if g > 1e-10: self.plot_constant_conductance(g, range=b_range, **style) for b, g_range in zip(b_minor, b_ranges): self.plot_constant_susceptance(b, range=g_range, **style) # ========== HELPER METHODS (DOMAIN-AGNOSTIC) ========== def _compute_major_ranges(self, xticks, yticks, threshold): """Compute clipping ranges for major gridlines in fancy mode. This is domain-agnostic - it works on ABSOLUTE tick values and returns ABSOLUTE ranges. Works for both impedance and admittance. Args: xticks: Major real-axis tick values (sorted, ABSOLUTE) yticks: Major imaginary-axis tick values (sorted, ABSOLUTE) threshold: Möbius distance threshold (single value or tuple) Returns: tuple: (real_ranges, imag_ranges) where each is a list of ranges Range is either None (full circle) or (min, max) tuple """ thr_x, thr_y = self._split_threshold(threshold) # Check if yticks are symmetric (required for fancy) try: yticks_pos = self._check_fancy(yticks) except ValueError: # Fall back to no clipping return [None] * len(xticks), [None] * len(yticks) # Initialize: all circles drawn fully real_ranges = [None] * len(xticks) imag_ranges = [None] * len(yticks) # X=0 line (real axis) always drawn fully imag_ranges[len(yticks) // 2] = None # Center index is X=0 # Clip imaginary circles at real values tmp_yticks = yticks_pos.copy() for i, r in enumerate(xticks): if r >= SC_NEAR_INFINITY: continue k = 1 while k < len(tmp_yticks): x0, x1 = tmp_yticks[k - 1 : k + 1] # Check Möbius distance if abs(self.moebius_z(r, x0) - self.moebius_z(r, x1)) < thr_x: # Clip this imaginary circle at this real value idx_pos = np.where(np.abs(yticks - x1) < SC_EPSILON)[0] idx_neg = np.where(np.abs(yticks - (-x1)) < SC_EPSILON)[0] if len(idx_pos) > 0: imag_ranges[idx_pos[0]] = (0, r) if len(idx_neg) > 0: imag_ranges[idx_neg[0]] = (0, r) tmp_yticks = np.delete(tmp_yticks, k) else: k += 1 # Clip real circles at imaginary values for i in range(1, len(yticks_pos)): x0, x1 = yticks_pos[i - 1 : i + 1] k = 1 tmp_xticks = xticks.copy() while k < len(tmp_xticks): r0, r1 = tmp_xticks[k - 1 : k + 1] if abs(self.moebius_z(r0, x1) - self.moebius_z(r1, x1)) < thr_y: # Clip this real circle at this imaginary range idx = np.where(np.abs(xticks - r1) < SC_EPSILON)[0] if len(idx) > 0: real_ranges[idx[0]] = (-x0, x0) tmp_xticks = np.delete(tmp_xticks, k) else: k += 1 return real_ranges, imag_ranges def _inherit_major_ranges(self, x_minor, y_minor, xt_major, yt_major, major_real_ranges, major_imag_ranges): """Compute clipping ranges for minor gridlines by inheriting from major gridlines. For each minor tick, find the nearest major ticks on either side and use the most restrictive clipping range (minimum of the max values). Args: x_minor: Minor real-axis tick values (sorted, ABSOLUTE) y_minor: Minor imaginary-axis tick values (sorted, ABSOLUTE) xt_major: Major real-axis tick values (sorted, ABSOLUTE) yt_major: Major imaginary-axis tick values (sorted, ABSOLUTE) major_real_ranges: Clipping ranges for major real gridlines major_imag_ranges: Clipping ranges for major imaginary gridlines Returns: tuple: (real_ranges, imag_ranges) for minor gridlines """ real_ranges = [] imag_ranges = [] # For each minor real tick, inherit range from nearest major real ticks for r_minor in x_minor: # Find major ticks on either side idx_before = np.where(xt_major <= r_minor)[0] idx_after = np.where(xt_major >= r_minor)[0] range_before = major_real_ranges[idx_before[-1]] if len(idx_before) > 0 else None range_after = major_real_ranges[idx_after[0]] if len(idx_after) > 0 else None # Use the most restrictive range (smallest absolute max value) if range_before is None and range_after is None: real_ranges.append(None) elif range_before is None: real_ranges.append(range_after) elif range_after is None: real_ranges.append(range_before) else: # Both have ranges - take minimum of the max values # Ranges are symmetric: (-x, x) so we just compare the positive value max_before = abs(range_before[1]) if range_before[1] != 0 else abs(range_before[0]) max_after = abs(range_after[1]) if range_after[1] != 0 else abs(range_after[0]) min_max = min(max_before, max_after) real_ranges.append((-min_max, min_max)) # For each minor imaginary tick, inherit range from nearest major imaginary ticks # Note: y_minor and yt_major contain both positive and negative values, already sorted for y_minor_val in y_minor: # Find major ticks on either side (in the SORTED array, not by absolute value) idx_before = np.where(yt_major <= y_minor_val)[0] idx_after = np.where(yt_major >= y_minor_val)[0] range_before = major_imag_ranges[idx_before[-1]] if len(idx_before) > 0 else None range_after = major_imag_ranges[idx_after[0]] if len(idx_after) > 0 else None # Use the most restrictive range (smallest max value) if range_before is None and range_after is None: imag_ranges.append(None) elif range_before is None: imag_ranges.append(range_after) elif range_after is None: imag_ranges.append(range_before) else: # Both have ranges - take minimum of the max values max_before = range_before[1] max_after = range_after[1] min_max = min(max_before, max_after) imag_ranges.append((0, min_max)) return real_ranges, imag_ranges def _compute_minor_ticks(self, xt_major, yt_major, real_divs, imag_divs, threshold): """Compute minor tick positions with nice spacing. This is domain-agnostic - it works on ABSOLUTE major tick values and returns ABSOLUTE minor tick values. Works for both impedance and admittance. Args: xt_major: Major real-axis tick values (sorted, ABSOLUTE) yt_major: Major imaginary-axis tick values (sorted, ABSOLUTE) real_divs: Max divisions for real axis (or None for automatic) imag_divs: Max divisions for imaginary axis (or None for automatic) threshold: Integer that determines tick spacing Returns: tuple: (x_minor, y_minor) arrays of minor tick positions (ABSOLUTE) """ # Check if yticks are symmetric try: yt_pos = self._check_fancy(yt_major) except ValueError: # Fall back to simple locator x_minor = self.xaxis.minor.locator() y_minor = self.yaxis.minor.locator() return x_minor, y_minor dividers = np.array([1, 2, 3, 5]) thr_x, thr_y = self._split_threshold(threshold) if real_divs is None: x_dividers = list(dividers) else: x_dividers = [d for d in dividers if d <= real_divs] or [real_divs] if imag_divs is None: y_dividers = list(dividers) else: y_dividers = [d for d in dividers if d <= imag_divs] or [imag_divs] # Use midpoint for distance calculations ym = self.imag_interp1d([yt_pos[0], yt_pos[-1]], 2)[1] xm = self.real_interp1d([xt_major[0], xt_major[-1]], 2)[1] x_minor = [] for x0, x1 in zip(xt_major[:-1], xt_major[1:]): if x1 >= SC_NEAR_INFINITY: continue div = choose_minor_divider( x0, x1, x_dividers, thr_x, map_func=lambda x, ym=ym: self.moebius_z(x, ym), max_divisions=real_divs, ) if div > 1: x_minor.extend(np.linspace(x0, x1, div + 1)[1:-1]) y_minor = [] for y0, y1 in zip(yt_pos[:-1], yt_pos[1:]): div = choose_minor_divider( y0, y1, y_dividers, thr_y, map_func=lambda y, xm=xm: self.moebius_z(xm, y), max_divisions=imag_divs, ) if div > 1: y_minor.extend(np.linspace(y0, y1, div + 1)[1:-1]) x_minor = np.unique(np.round(np.asarray(x_minor), 7)) y_minor = np.unique(np.round(np.asarray(y_minor), 7)) # Add negative y values y_minor_full = [] for y in y_minor: y_minor_full.append(y) if abs(y) > SC_EPSILON: y_minor_full.append(-y) return x_minor, np.array(y_minor_full) def _check_fancy(self, yticks): """Check if imaginary axis ticks are symmetric about zero.""" len_y = (len(yticks) - 1) // 2 if not (len(yticks) % 2 == 1 and (yticks[len_y:] + yticks[len_y::-1] < SC_EPSILON).all()): raise ValueError("Fancy grid requires zero-symmetric imaginary ticks") return yticks[len_y:] def _split_threshold(self, threshold): """Split threshold into x and y components and convert to Möbius distance. Args: threshold: Either a legacy numeric value (will be divided by 1000), a tuple of legacy values, or a string like "2mm" for physical distance. Can also be a tuple of strings like ("2mm", "1.5mm"). Returns: tuple: (thr_x, thr_y) in Möbius distance units """ def convert_threshold(thr): """Convert a single threshold value to Möbius distance.""" if isinstance(thr, str) and thr.endswith("mm"): # Physical distance in millimeters mm_value = float(thr[:-2]) # Convert mm to Möbius distance using current figure size return self._mm_to_moebius(mm_value) # Legacy numeric value - divide by 1000 return thr / 1000 if isinstance(threshold, tuple): thr_x = convert_threshold(threshold[0]) thr_y = convert_threshold(threshold[1]) else: thr_x = thr_y = convert_threshold(threshold) return (thr_x, thr_y) def _mm_to_moebius(self, mm): """Convert physical distance in mm to Möbius distance. Args: mm: Distance in millimeters Returns: float: Equivalent distance in Möbius space """ # Get the bounding box of the axes in display coordinates (pixels) bbox = self.get_window_extent() # The Smith chart radius in display coordinates r = self._get_key("axes.radius") # Smith chart diameter in pixels (width of the circular chart area) # The chart goes from -r to +r in axes coordinates, which is 2*r # The axes coordinates go from 0 to 1, so we need the full axes width chart_diameter_pixels = bbox.width * 2 * r # Convert mm to inches (1 inch = 25.4 mm), then to pixels using DPI dpi = self.figure.dpi mm_to_pixels = dpi / 25.4 distance_pixels = mm * mm_to_pixels # Convert pixels to fraction of chart diameter (this gives us data space distance) # In Möbius space, the chart has diameter 2 (from -1 to +1) moebius_distance = (distance_pixels / chart_diameter_pixels) * 2 return moebius_distance def _get_grid_style(self, grid, level, **user_kwargs): """Get styling parameters for a grid.""" if grid == "impedance": prefix = f"grid.Z.{level}" else: prefix = f"grid.Y.{level}" style = {} style["color"] = self._get_key(f"{prefix}.color") style["linestyle"] = self._get_key(f"{prefix}.linestyle") style["linewidth"] = self._get_key(f"{prefix}.linewidth") style["alpha"] = self._get_key(f"{prefix}.alpha") style["zorder"] = self._get_key("grid.zorder") if level == "minor": try: style["dashes"] = self._get_key(f"{prefix}.dashes") style["dash_capstyle"] = self._get_key(f"{prefix}.capstyle") except KeyError: pass style.update(user_kwargs) return style