"""Plotting functionality for SmithAxes."""
from collections.abc import Iterable
from numbers import Number
import numpy as np
from matplotlib.axes import Axes
from scipy.interpolate import splprep, splev
from pysmithchart.constants import Z_DOMAIN, Y_DOMAIN, R_DOMAIN, NORM_Z_DOMAIN, NORM_Y_DOMAIN
from pysmithchart import utils
# Only export the mixin class, not imported symbols
__all__ = ["PlottingMixin"]
[docs]
class PlottingMixin:
"""Mixin class providing plotting methods for SmithAxes.
This class is designed to be used as a mixin with matplotlib.axes.Axes
via multiple inheritance. Attributes like _current_zorder are provided
by the SmithAxes class through the AxesCore mixin.
"""
# pylint: disable=no-member # Attributes come from AxesCore mixin
[docs]
def plot(self, *args, **kwargs):
"""
Plot data on the Smith Chart.
This method extends the functionality of :meth:`matplotlib.axes.Axes.plot` to
support Smith Chart-specific features, including handling of complex data and
additional keyword arguments for customization.
Args:
*args:
Positional arguments for the data to plot. Supports real and complex
data. Complex data should either be of type `complex` or a
`numpy.ndarray` with `dtype=complex`.
**kwargs:
Keyword arguments for customization. Includes all arguments supported
by :meth:`matplotlib.axes.Axes.plot`, along with the following:
domain (str, optional):
Specifies the input data format
- `Z_DOMAIN`: (default) Impedance in Ohms.
- `R_DOMAIN`: Gamma or scattering parameters
- `NORM_Z_DOMAIN`: Normalized impedance.
- `Y_DOMAIN`: Admittance in Siemens
- `NORM_Y_DOMAIN`: Normalized admittance.
interpolate (bool or int, optional):
If `True`, interpolates the given data linearly with a default step size.
If an integer, specifies the number of interpolation steps.
Defaults to `False`.
equipoints (bool or int, optional):
If `True`, interpolates the data to equidistant points. If an integer,
specifies the number of equidistant points. Cannot be used with
`interpolate`. Defaults to `False`.
arrow (str, bool, or dict, optional):
Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end of line
- 'start': Arrow at start of line
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
Returns:
list[matplotlib.lines.Line2D]:
A list of line objects representing the plotted data.
Examples:
Plot impedance data on a Smith Chart:
>>> import matplotlib.pyplot as plt
>>> import pysmithchart
>>> ZL = [30 + 30j, 50 + 50j, 100 + 100j]
>>> plt.subplot(1, 1, 1, projection="smith")
>>> plt.plot(ZL, "b", marker="o", markersize=10, domain=pysmithchart.Z_DOMAIN)
>>> plt.show()
Plot with arrow showing direction:
>>> ZL = [30 + 30j, 50 + 50j, 100 + 100j]
>>> plt.subplot(1, 1, 1, projection="smith")
>>> plt.plot(ZL, "r-", arrow='end', linewidth=2)
>>> plt.show()
"""
domain = kwargs.pop("domain", self._get_key("plot.default.domain"))
domain = utils.validate_domain(domain)
arrow = kwargs.pop("arrow", None) # Extract arrow parameter
# Parse arguments into x, y pairs and other args (like format strings)
new_args = ()
i = 0
while i < len(args):
arg = args[i]
# If it's a string (format specifier), pass through
if isinstance(arg, str):
new_args += (arg,)
i += 1
continue
# Check if it's a complex number or array of complex numbers
is_complex = False
if isinstance(arg, Number):
is_complex = np.iscomplexobj(arg)
elif isinstance(arg, Iterable):
try:
arr = np.asarray(arg)
is_complex = np.iscomplexobj(arr)
except (ValueError, TypeError):
pass
if is_complex:
# Handle complex input: convert to array and split into x, y
if isinstance(arg, Number):
arg = np.array([arg])
else:
arg = np.asarray(arg)
new_args += utils.z_to_xy(arg)
i += 1
else:
# Not complex - check if next arg could be y-values
if i + 1 < len(args) and not isinstance(args[i + 1], str):
# We have two consecutive non-string args - treat as x, y
x_arg = arg
y_arg = args[i + 1]
# Convert to arrays
if isinstance(x_arg, Number):
x_arr = np.array([x_arg])
else:
x_arr = np.asarray(x_arg)
if isinstance(y_arg, Number):
y_arr = np.array([y_arg])
else:
y_arr = np.asarray(y_arg)
new_args += (x_arr, y_arr)
i += 2
else:
# Single real number or array - treat as x with y=0
if isinstance(arg, Number):
x_arr = np.array([arg])
else:
x_arr = np.asarray(arg)
y_arr = np.zeros_like(x_arr)
new_args += (x_arr, y_arr)
i += 1
if "zorder" not in kwargs:
kwargs["zorder"] = self._current_zorder
self._current_zorder += 0.001
interpolate = kwargs.pop("interpolate", False)
equipoints = kwargs.pop("equipoints", False)
# Only set default marker if no format string in args and no marker in kwargs
has_format_string = any(isinstance(arg, str) and arg for arg in new_args)
if not has_format_string:
kwargs.setdefault("marker", self._get_key("plot.marker.default"))
if interpolate:
if equipoints > 0:
raise ValueError("Interpolation is not available with equidistant markers")
interpolation = self._get_key("plot.default.interpolation")
if interpolation < 0:
raise ValueError("Interpolation is only for positive values possible!")
if "markevery" in kwargs:
mark = kwargs["markevery"]
if isinstance(mark, Iterable):
mark = np.asarray(mark) * (interpolate + 1)
else:
mark *= interpolate + 1
kwargs["markevery"] = mark
lines = Axes.plot(self, *new_args, **kwargs)
for line in lines:
cdata = utils.xy_to_z(line.get_data())
# Apply unified domain transformation
x_transformed, y_transformed = self._apply_domain_transform(cdata, domain=domain, warn_s_parameter=True)
line.set_data(x_transformed, y_transformed)
if interpolate or equipoints:
z = self.moebius_z(*line.get_data())
if len(z) > 1:
spline, t0 = splprep(utils.z_to_xy(z), s=0) # pylint: disable=unbalanced-tuple-unpacking
ilen = (interpolate + 1) * (len(t0) - 1) + 1
if equipoints == 1:
t = np.linspace(0, 1, ilen)
elif equipoints > 1:
t = np.linspace(0, 1, equipoints)
else:
t = np.zeros(ilen)
t[0], t[1:] = (
t0[0],
np.concatenate(
[np.linspace(i0, i1, interpolate + 2)[1:] for i0, i1 in zip(t0[:-1], t0[1:])]
),
)
z = self.moebius_inv_z(*splev(t, spline))
line.set_data(utils.z_to_xy(z))
# Add arrows if requested
if arrow and lines:
for line in lines:
self._add_arrows_to_line(line, arrow)
return lines
[docs]
def scatter(self, x, y=None, domain=None, **kwargs):
"""
Create a scatter plot on the Smith Chart.
Args:
x: X coordinates (real part) or complex impedance/admittance values.
y: Y coordinates (imaginary part). Ignored if x is complex.
domain (str, optional):
Specifies the input data format
- `Z_DOMAIN`: (default) Impedance in Ohms.
- `R_DOMAIN`: Gamma or scattering parameters
- `NORM_Z_DOMAIN`: Normalized impedance.
- `Y_DOMAIN`: Admittance in Siemens
- `NORM_Y_DOMAIN`: Normalized admittance.
**kwargs: Additional arguments passed to matplotlib.axes.Axes.scatter (s, c, marker, etc.).
Returns:
PathCollection: The scatter plot collection.
Examples:
>>> # Recommended: use keyword arguments
>>> ax.scatter(50+25j, s=50, c='red', marker='o')
>>> # Also works: passing real and imaginary parts separately
>>> ax.scatter(50, 25, s=50, c='red', marker='o')
"""
# Get domain
if domain is None:
domain = self._get_key("plot.default.domain")
domain = utils.validate_domain(domain)
x_plot, y_plot = self._apply_domain_transform(x, y, domain=domain, warn_s_parameter=True)
# Set zorder
if "zorder" not in kwargs:
kwargs["zorder"] = self._current_zorder
self._current_zorder += 0.001
# Call matplotlib scatter with separate x and y
return Axes.scatter(self, x_plot, y_plot, **kwargs)
def _add_arrows_to_line(self, line, arrow=None):
"""
Add arrows to a plotted line.
This is a helper method used by plot functions to add directional arrows
to curves on the Smith chart.
Args:
line (matplotlib.lines.Line2D): The line object to add arrows to.
arrow (str, bool, or dict, optional): Arrow specification.
- None or False: No arrows (default)
- True or 'end': Arrow at end of line
- 'start': Arrow at start of line
- 'both': Arrows at both ends
- dict: Full control with keys:
- 'position': 'start', 'end', or 'both' (default: 'end')
- 'style': matplotlib arrowstyle (default: '->')
- 'size': mutation_scale for arrow size (default: 15)
- 'offset': number of points from end to use for arrow direction (default: 1)
Returns:
list: List of annotation objects created for the arrows.
Examples:
>>> lines = ax.plot([1+1j, 2+2j], 'r-')
>>> ax._add_arrows_to_line(lines[0], arrow='end')
>>> lines = ax.plot([1+1j, 2+2j], 'b-')
>>> ax._add_arrows_to_line(lines[0], arrow={'position': 'both', 'size': 20})
"""
if not arrow:
return []
# Get line data - these are already in the transformed (display) coordinates
x, y = line.get_data()
# Need at least 2 points for an arrow
if len(x) < 2:
return []
# Parse arrow parameter
if arrow is True or arrow == "end":
arrow_spec = {"position": "end", "style": "->", "size": 15, "offset": 1}
elif isinstance(arrow, str):
arrow_spec = {"position": arrow, "style": "->", "size": 15, "offset": 1}
elif isinstance(arrow, dict):
arrow_spec = {
"position": arrow.get("position", "end"),
"style": arrow.get("style", "->"),
"size": arrow.get("size", 15),
"offset": arrow.get("offset", 1),
}
else:
return []
# Extract arrow properties
position = arrow_spec["position"]
style = arrow_spec["style"]
size = arrow_spec["size"]
offset = arrow_spec["offset"]
# Get visual properties from the line
color = line.get_color()
lw = line.get_linewidth()
# Arrow properties
arrow_props = {"arrowstyle": style, "lw": lw, "color": color, "mutation_scale": size}
annotations = []
# Add arrow at start
# The key fix: use the line's transform (which is self.transData for Smith chart)
# This ensures arrows are drawn in the same coordinate system as the line
if position in ["start", "both"]:
if len(x) > offset:
ann = Axes.annotate(
self,
"",
xy=(x[offset], y[offset]),
xytext=(x[0], y[0]),
xycoords="data",
textcoords="data",
arrowprops=arrow_props,
)
annotations.append(ann)
# Add arrow at end
if position in ["end", "both"]:
if len(x) > offset:
ann = Axes.annotate(
self,
"",
xy=(x[-1], y[-1]),
xytext=(x[-(offset + 1)], y[-(offset + 1)]),
xycoords="data",
textcoords="data",
arrowprops=arrow_props,
)
annotations.append(ann)
return annotations
[docs]
def text(self, x, y=None, s=None, domain=None, **kwargs):
"""
Add text to the Smith chart at the specified coordinates.
Args:
x (float or complex): X-coordinate or complex coordinate.
y (float, optional): Y-coordinate. Not needed if x is complex.
s (str, optional): Text string to display.
domain (str, optional): Coordinate domain (Z_DOMAIN, Y_DOMAIN, etc.).
Defaults to plot.default.domain from configuration.
transform (Transform, optional): Matplotlib transform to use.
If None or 'data', uses Smith chart transformation.
Otherwise uses the specified transform (e.g., transAxes).
**kwargs: Additional arguments passed to matplotlib.axes.Axes.text().
Returns:
matplotlib.text.Text: The created text object.
Examples:
>>> # Text at impedance coordinates (default behavior)
>>> ax.text(50, 25, "Point A") # Real and imaginary parts
>>> ax.text(50+25j, "Point A") # Complex impedance
>>> # Text in axes coordinates (0-1 range, no transformation)
>>> ax.text(0.5, 0.95, "Title", transform=ax.transAxes, ha='center')
>>> # Text with styling
>>> ax.text(75+50j, "Load", fontsize=12, color='red', ha='left')
"""
# Extract transform from kwargs if present
transform = kwargs.pop("transform", None)
# Handle complex input: text(complex, string, ...)
if np.iscomplexobj(x):
if y is None:
raise ValueError("When x is complex, y must be the text string")
# x is complex, y is the string, s is actually in kwargs or None
if s is not None:
# User passed text(complex, something, something_else)
# This is ambiguous, but likely: text(z, text, domain=...)
# Put s back into kwargs as domain if it's a valid domain
if s in [R_DOMAIN, Z_DOMAIN, NORM_Z_DOMAIN, Y_DOMAIN]:
domain = s
s = y # y is the text string
else:
s = y # y is the text string
# Split complex into real and imaginary
x, y = np.real(x), np.imag(x)
elif y is None:
raise ValueError("Must provide both x and y coordinates, or a complex coordinate")
elif s is None:
raise ValueError("Must provide text string")
# Check if we should apply Smith chart transformation
if self._should_transform_coordinates(transform):
# Get default domain if not specified
if domain is None:
domain = self._get_key("plot.default.domain")
# Transform coordinates using the helper method
x_transformed, y_transformed = self._transform_coordinates(x, y, domain)
# Call parent with transformed coordinates
# Don't pass transform parameter - let matplotlib use default (transData)
return super().text(x_transformed, y_transformed, s, **kwargs)
# User specified a non-data transform, use coordinates as-is
return super().text(x, y, s, transform=transform, **kwargs)
[docs]
def annotate(
self,
text,
xy,
xytext=None,
xycoords="data",
textcoords=None,
domain=None,
domain_text=None,
arrowprops=None,
annotation_clip=None,
**kwargs,
):
"""
Add an annotation (text with optional arrow) to the Smith chart.
Args:
text (str): The text of the annotation.
xy (tuple): The point (x, y) to annotate.
xytext (tuple, optional): Position (x, y) for the text. If None, text is placed at xy.
xycoords (str or Transform, optional): Coordinate system for xy.
Default is 'data'. Can be 'data', 'axes', 'figure', or a Transform.
Only 'data' coordinates are transformed according to domain.
textcoords (str or Transform, optional): Coordinate system for xytext.
Defaults to xycoords value.
domain (str, optional): Coordinate type for xy (IMPEDANCE, ADMITTANCE, or REFLECTION domain).
Only used when xycoords is 'data' or not specified.
domain_text (str, optional): Coordinate type for xytext.
Only used when textcoords is 'data'. Defaults to domain value.
arrowprops (dict, optional): Arrow properties.
annotation_clip (bool, optional): Whether to clip annotation.
**kwargs: Additional matplotlib annotate parameters.
Returns:
matplotlib.text.Annotation: The annotation object.
"""
# Determine if we should transform xy coordinates
if self._should_transform_coordinates(xycoords):
# Get default domain if not specified
if domain is None:
domain = self._get_key("plot.default.domain")
# Transform xy coordinates (the point being annotated)
xy_transformed = self._transform_coordinates(xy[0], xy[1], domain)
else:
# xycoords is not 'data', use coordinates as-is
xy_transformed = xy
# Handle xytext coordinates if provided
if xytext is not None:
# If textcoords not specified, it defaults to xycoords
if textcoords is None:
textcoords = xycoords
# Determine if we should transform xytext coordinates
if self._should_transform_coordinates(textcoords):
# If domain_text not specified, use same as domain
if domain_text is None:
domain_text = domain if domain is not None else self._get_key("plot.default.domain")
# Transform xytext coordinates
xytext_transformed = self._transform_coordinates(xytext[0], xytext[1], domain_text)
else:
# textcoords is not 'data', use coordinates as-is
xytext_transformed = xytext
else:
xytext_transformed = None
# Call parent annotate with transformed coordinates
return super().annotate(
text,
xy=xy_transformed,
xytext=xytext_transformed,
xycoords=xycoords,
textcoords=textcoords,
arrowprops=arrowprops,
annotation_clip=annotation_clip,
**kwargs,
)
[docs]
def legend(self, *_args, **kwargs):
"""
Create and display a legend for the Smith chart, filtering duplicate entries.
This method filters out duplicate legend labels, keeping only the first occurrence.
Args:
*args:
Positional arguments passed directly to `matplotlib.axes.Axes.legend`.
**kwargs:
Keyword arguments for configuring the legend. Includes all standard arguments
supported by `matplotlib.axes.Axes.legend`, such as:
- loc: Location of the legend (e.g., 'upper right', 'lower left').
- fontsize: Font size for the legend text.
- ncol: Number of columns in the legend.
- title: Title for the legend.
- framealpha: Transparency of the legend background (default: 1.0 for opaque).
See the Matplotlib documentation for more details.
Returns:
matplotlib.legend.Legend:
The legend instance created for the Smith chart.
"""
# Get handles and labels, filtering out duplicates
handles, labels = self.get_legend_handles_labels()
seen_labels = set()
unique_handles = []
unique_labels = []
for handle, label in zip(handles, labels):
if label not in seen_labels:
seen_labels.add(label)
unique_handles.append(handle)
unique_labels.append(label)
# Set default framealpha to 1.0 (opaque) if not specified
kwargs.setdefault("framealpha", 1.0)
return Axes.legend(self, unique_handles, unique_labels, **kwargs)
[docs]
def plot_constant_resistance(self, norm_resistance, *args, range=None, num_points=500, arrow=None, **kwargs):
"""
Plot a constant resistance circle on the Smith chart.
Args:
norm_resistance (float or complex): Normalized resistance value (r = R/Z₀, unitless).
For example, r=1.0 represents Z₀, r=2.0 represents 2×Z₀, etc.
If complex, only the real part is used.
*args: Optional format string (e.g., 'r-', 'b--', 'go').
range (tuple, optional): The (min, max) normalized reactance range to plot.
If None, draws a complete circle. If specified, draws an arc between
the min and max reactance values (in normalized units).
If range values are complex, only the imaginary parts are used.
num_points (int, optional): Number of points to use for the circle (default: 500).
arrow (str, bool, or dict, optional): Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional keyword arguments passed to plot() (e.g., color, linestyle, label).
Returns:
list[matplotlib.lines.Line2D]: The plotted line objects.
Examples:
>>> # Plot r=1.0 constant resistance circle (matches Z₀)
>>> ax.plot_constant_resistance(1.0, 'r-', label='r = 1.0')
>>> # Plot r=2.0 with arrow showing direction
>>> ax.plot_constant_resistance(2.0, 'b-', arrow='end', label='r = 2.0')
>>> # Plot arc with limited reactance range
>>> ax.plot_constant_resistance(0.5, 'g--', range=(-1, 1), arrow='both')
>>> # Plot r=0.5 (half of Z₀)
>>> ax.plot_constant_resistance(0.5, color='orange', linewidth=2)
>>> # Plot using complex impedance (only real part used)
>>> ax.plot_constant_resistance(1.5+0.8j, 'g-') # Plots r=1.5
Notes:
On a Smith chart, constant resistance forms a circle. The circle is parametrized
by varying the reactance from -∞ to +∞. For a complete circle, the function uses
angular parametrization. For a partial arc, it uses the specified reactance range.
All values are normalized (unitless). To plot physical values in Ohms, divide by Z₀.
"""
# Extract real part if complex
if np.iscomplexobj(norm_resistance):
norm_resistance = norm_resistance.real
if range is None:
# Draw complete circle using angular parametrization
theta = np.linspace(-np.pi / 2 + 0.01, np.pi / 2 - 0.01, num_points)
else:
# Convert reactance range to theta range for uniform spacing
# Extract imaginary part if complex, otherwise use as-is (already a reactance value)
x_min = range[0].imag if np.iscomplexobj(range[0]) else range[0]
x_max = range[1].imag if np.iscomplexobj(range[1]) else range[1]
theta_min = np.arctan2(x_min, norm_resistance)
theta_max = np.arctan2(x_max, norm_resistance)
theta = np.linspace(theta_min, theta_max, num_points)
# Use tangent parametrization for uniform point spacing along the arc
x_vals = norm_resistance * np.tan(theta)
# Generate impedance points: Z = r + jx
z_points = norm_resistance + 1j * x_vals
# Plot in NORM_Z_DOMAIN
if args:
return self.plot(z_points, *args, domain=NORM_Z_DOMAIN, arrow=arrow, **kwargs)
return self.plot(z_points, domain=NORM_Z_DOMAIN, arrow=arrow, **kwargs)
[docs]
def plot_constant_reactance(self, norm_reactance, *args, range=None, num_points=200, arrow=None, **kwargs):
"""
Plot a constant reactance arc on the Smith chart.
Args:
norm_reactance (float or complex): Normalized reactance value (x = X/Z₀, unitless).
Positive for inductive reactance, negative for capacitive reactance.
If complex, only the imaginary part is used.
*args: Optional format string (e.g., 'r-', 'b--', 'go').
range (tuple, optional): The (min, max) normalized resistance range to plot.
If None, automatically determines range to show the full arc.
If range values are complex, only the real parts are used.
num_points (int, optional): Number of points to use for the arc (default: 200).
arrow (str, bool, or dict, optional): Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional keyword arguments passed to plot() (e.g., color, linestyle, label).
Returns:
list[matplotlib.lines.Line2D]: The plotted line objects.
Examples:
>>> # Plot x=+1.0 constant reactance arc (inductive)
>>> ax.plot_constant_reactance(1.0, 'r-', label='x = +1.0 (inductive)')
>>> # Plot x=-1.0 arc (capacitive)
>>> ax.plot_constant_reactance(-1.0, 'b-', label='x = -1.0 (capacitive)')
>>> # Plot with custom resistance range
>>> ax.plot_constant_reactance(0.5, 'g--', range=(0, 5))
>>> # Plot with arrow
>>> ax.plot_constant_reactance(2.0, color='orange', arrow='end')
>>> # Plot using complex impedance (only imaginary part used)
>>> ax.plot_constant_reactance(0.8+1.5j, 'm-') # Plots x=1.5
Notes:
On a Smith chart, constant reactance forms circular arcs. The arcs are parametrized
by varying the resistance from 0 to ∞. Positive reactance (inductive) appears in the
upper half of the chart, negative reactance (capacitive) in the lower half.
All values are normalized (unitless). To plot physical values in Ohms, divide by Z₀.
"""
# Extract imaginary part if complex
if np.iscomplexobj(norm_reactance):
norm_reactance = norm_reactance.imag
# Determine resistance range if not specified
if range is None:
range = (0.01, 10)
# Generate points along constant reactance: Z = r + jx
r_vals = np.linspace(range[0].real, range[1].real, num_points)
z_points = r_vals + 1j * norm_reactance
# Plot in NORM_Z_DOMAIN - let plot() handle transformation
if args:
lines = self.plot(z_points, *args, domain=NORM_Z_DOMAIN, **kwargs)
else:
lines = self.plot(z_points, domain=NORM_Z_DOMAIN, **kwargs)
# Add arrows if requested
if arrow and lines:
self._add_arrows_to_line(lines[0], arrow)
return lines
[docs]
def plot_constant_conductance(self, norm_conductance, *args, range=None, num_points=500, arrow=None, **kwargs):
"""
Plot a constant conductance circle on the Smith chart (admittance chart).
Constant conductance forms a circle on an admittance Smith chart, just as
constant resistance forms a circle on an impedance Smith chart.
Args:
norm_conductance (float or complex): Normalized conductance value (g = G×Z₀, unitless).
For example, g=1.0 represents Y₀ (where Y₀=1/Z₀).
If complex, only the real part is used.
*args: Optional format string (e.g., 'r-', 'b--', 'go').
range (tuple, optional): The (min, max) normalized susceptance range to plot.
If None, draws a complete circle.
If range values are complex, only the imaginary parts are used.
num_points (int, optional): Number of points to use for the circle (default: 500).
arrow (str, bool, or dict, optional): Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional keyword arguments passed to plot() (e.g., color, linestyle, label).
Returns:
list[matplotlib.lines.Line2D]: The plotted line objects.
Examples:
>>> # Plot g=1.0 constant conductance circle (matches Y₀)
>>> ax.plot_constant_conductance(1.0, 'r-', label='g = 1.0')
>>> # Plot g=2.0 with arrow
>>> ax.plot_constant_conductance(2.0, 'b-', arrow='end', label='g = 2.0')
>>> # Plot with custom susceptance range
>>> ax.plot_constant_conductance(0.5, 'g--', range=(-2, 2))
>>> # Plot g=0.5 (half of Y₀)
>>> ax.plot_constant_conductance(0.5, color='orange', linewidth=2)
>>> # Plot using complex admittance (only real part used)
>>> ax.plot_constant_conductance(1.5+0.8j, 'g-') # Plots g=1.5
Notes:
On an admittance Smith chart, constant conductance forms a circle, just like
constant resistance on an impedance chart. The circle is parametrized by varying
susceptance from -∞ to +∞.
All values are normalized (unitless). To plot physical values in Siemens:
norm_conductance = G × Z₀, where G is in Siemens and Z₀ is in Ohms.
"""
# Extract real part if complex
if np.iscomplexobj(norm_conductance):
norm_conductance = norm_conductance.real
if range is None:
# Draw complete circle using angular parametrization
theta = np.linspace(-np.pi / 2 + 0.01, np.pi / 2 - 0.01, num_points)
else:
# Convert susceptance range to theta range for uniform spacing
# Extract imaginary part if complex, otherwise use as-is (already a susceptance value)
b_min = range[0].imag if np.iscomplexobj(range[0]) else range[0]
b_max = range[1].imag if np.iscomplexobj(range[1]) else range[1]
theta_min = np.arctan2(b_min, norm_conductance)
theta_max = np.arctan2(b_max, norm_conductance)
theta = np.linspace(theta_min, theta_max, num_points)
# Use tangent parametrization for uniform point spacing along the circle
b_vals = norm_conductance * np.tan(theta)
# Generate admittance points: Y = g + jb
y_points = norm_conductance + 1j * b_vals
# Plot in NORM_Y_DOMAIN - let plot() handle transformation
if args:
lines = self.plot(y_points, *args, domain=NORM_Y_DOMAIN, arrow=arrow, **kwargs)
else:
lines = self.plot(y_points, domain=NORM_Y_DOMAIN, arrow=arrow, **kwargs)
return lines
[docs]
def plot_constant_susceptance(self, norm_susceptance, *args, range=None, num_points=200, arrow=None, **kwargs):
"""
Plot a constant susceptance arc on the Smith chart (admittance chart).
Constant susceptance forms an arc on an admittance Smith chart, just as
constant reactance forms an arc on an impedance Smith chart.
Args:
norm_susceptance (float or complex): Normalized susceptance value (b = B×Z₀, unitless).
Positive for capacitive susceptance, negative for inductive susceptance.
If complex, only the imaginary part is used.
*args: Optional format string (e.g., 'r-', 'b--', 'go').
range (tuple, optional): The (min, max) normalized conductance range to plot.
If None, automatically determines range to show the full arc.
If range values are complex, only the real parts are used.
num_points (int, optional): Number of points to use for the arc (default: 200).
arrow (str, bool, or dict, optional): Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional keyword arguments passed to plot() (e.g., color, linestyle, label).
Returns:
list[matplotlib.lines.Line2D]: The plotted line objects.
Examples:
>>> # Plot b=+1.0 constant susceptance arc (capacitive)
>>> ax.plot_constant_susceptance(1.0, 'r-', label='b = +1.0 (capacitive)')
>>> # Plot b=-1.0 arc (inductive)
>>> ax.plot_constant_susceptance(-1.0, 'b-', label='b = -1.0 (inductive)')
>>> # Plot with custom conductance range
>>> ax.plot_constant_susceptance(0.5, 'g--', range=(0, 5))
>>> # Plot with arrow
>>> ax.plot_constant_susceptance(2.0, color='orange', arrow='end')
>>> # Plot using complex admittance (only imaginary part used)
>>> ax.plot_constant_susceptance(0.8+1.5j, 'm-') # Plots b=1.5
Notes:
On an admittance Smith chart, constant susceptance forms circular arcs. The arcs
are parametrized by varying conductance from 0 to ∞. Positive susceptance (capacitive)
appears in the upper half, negative susceptance (inductive) in the lower half.
Note: The sign convention is opposite to reactance - positive susceptance is capacitive,
while positive reactance is inductive.
All values are normalized (unitless). To plot physical values in Siemens:
norm_susceptance = B × Z₀, where B is in Siemens and Z₀ is in Ohms.
"""
# Extract imaginary part if complex
if np.iscomplexobj(norm_susceptance):
norm_susceptance = norm_susceptance.imag
# Determine conductance range if not specified
if range is None:
range = (0.01, 10)
# Generate points along constant susceptance: Y = g + jb
g_vals = np.linspace(range[0].real, range[1].real, num_points)
y_points = g_vals + 1j * norm_susceptance
# Plot in NORM_Y_DOMAIN - let plot() handle transformation
if args:
lines = self.plot(y_points, *args, domain=NORM_Y_DOMAIN, **kwargs)
else:
lines = self.plot(y_points, domain=NORM_Y_DOMAIN, **kwargs)
# Add arrows if requested
if arrow and lines:
self._add_arrows_to_line(lines[0], arrow)
return lines
[docs]
def plot_vswr(self, vswr, *args, angle_range=None, num_points=200, arrow=None, **kwargs):
r"""
Plot a constant VSWR circle on the Smith chart.
A constant VSWR circle represents all impedances with the same voltage standing
wave ratio. The circle is centered at the chart center with radius :math:`|\\Gamma|`.
Args:
vswr (float): The VSWR value to plot. Must be >= 1.0.
VSWR = 1.0 is a perfect match (center point).
VSWR = ∞ is the outer edge of the Smith chart.
*args: Optional format string (e.g., 'r-', 'b--', 'go').
angle_range (tuple, optional): The (start_angle, end_angle) in degrees to plot.
If None, plots the full circle (0° to 360°).
Angles are measured counterclockwise from the positive real axis.
num_points (int, optional): Number of points to use for the circle (default: 200).
arrow (str, bool, or dict, optional): Add directional arrow(s) to the curve.
- None/False: No arrows (default)
- True/'end': Arrow at end
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional keyword arguments passed to plot() (e.g., color, linestyle, label).
Returns:
list[matplotlib.lines.Line2D]: The plotted line objects.
Raises:
ValueError: If vswr < 1.0.
Examples:
>>> # Plot VSWR = 2.0 circle
>>> ax.plot_vswr(2.0, 'r-', label='VSWR = 2.0')
>>> # Plot partial arc from 0° to 180°
>>> ax.plot_vswr(1.5, 'b--', angle_range=(0, 180))
>>> # Plot VSWR = 3.0 with custom styling
>>> ax.plot_vswr(3.0, color='green', linestyle='--', linewidth=2)
"""
if vswr < 1.0:
raise ValueError(f"VSWR must be >= 1.0, got {vswr}")
# Calculate reflection coefficient magnitude from VSWR
# Gamma = (VSWR - 1) / (VSWR + 1)
gamma_mag = (vswr - 1) / (vswr + 1)
# Determine angle range
if angle_range is None:
angle_range = (0, 360)
# Generate points around the circle
angles = np.linspace(np.radians(angle_range[0]), np.radians(angle_range[1]), num_points)
# Create reflection coefficients on the circle
gamma = gamma_mag * np.exp(1j * angles)
# Plot the circle with optional format string
if args:
lines = self.plot(gamma, *args, domain=R_DOMAIN, **kwargs)
else:
lines = self.plot(gamma, domain=R_DOMAIN, **kwargs)
# Add arrows if requested
if arrow and lines:
self._add_arrows_to_line(lines[0], arrow)
return lines
[docs]
def plot_rotation_path(self, Z_start, Z_end, *args, domain=None, num_points=100, arrow=None, **kwargs):
"""
Plot a physically realizable impedance matching path.
For impedances at the same VSWR: Draws a single arc along the constant-VSWR circle.
For impedances at different VSWR: Draws a two-step path:
- Step 1: Rotate along constant-VSWR circle (transmission line)
- Step 2: Move toward center (reactive element)
Args:
Z_start: Starting impedance (complex number).
Z_end: Ending impedance (complex number).
*args: Optional format string (e.g., 'r-', 'b--').
domain: Domain for the impedances (Z_DOMAIN, NORM_Z_DOMAIN, Y_DOMAIN, R_DOMAIN).
Default: uses plot.default.domain from configuration.
num_points (int): Number of points for smooth path (default: 100).
arrow (str, bool, or dict, optional): Add directional arrow(s).
- For single arc (same VSWR): Arrow added to the arc
- For two-step path: Arrows added to both steps
- None/False: No arrows (default)
- True/'end': Arrow at end of each segment
- 'start': Arrow at start
- 'both': Arrows at both ends
- dict: {'position': 'end'/'start'/'both', 'style': '->', 'size': 15}
**kwargs: Additional plot arguments (color, linestyle, label, etc.).
Returns:
list: List of line objects. Single line for same VSWR, two lines otherwise.
Examples:
>>> # Same VSWR - single arc with arrow (impedance in Ohms)
>>> ax.plot_rotation_path(75+50j, 100+50j, 'r-', arrow='end', label='Rotation')
>>> # Different VSWR - two-step path (normalized impedance)
>>> ax.plot_rotation_path(1.5+1j, 1+0j, 'b--', domain=NORM_Z_DOMAIN, arrow='end')
>>> # Admittance-based matching
>>> ax.plot_rotation_path(0.02+0.01j, 0.02+0j, 'g-', domain=Y_DOMAIN, arrow='end')
Notes:
For same VSWR: Represents traveling along a lossless transmission line.
For different VSWR: Represents a matching network with transmission line + reactive element.
All coordinate transformations are handled by _apply_domain_transform().
"""
# Get default domain if not specified
if domain is None:
domain = self._get_key("plot.default.domain")
# Convert to complex if needed
if not isinstance(Z_start, complex):
if hasattr(Z_start, "__iter__"):
Z_start = complex(Z_start[0], Z_start[1])
else:
Z_start = complex(Z_start, 0)
if not isinstance(Z_end, complex):
if hasattr(Z_end, "__iter__"):
Z_end = complex(Z_end[0], Z_end[1])
else:
Z_end = complex(Z_end, 0)
# Transform start and end points to normalized impedance using the unified function
# This handles all domain types (Z_DOMAIN, NORM_Z_DOMAIN, Y_DOMAIN, R_DOMAIN)
x_start, y_start = self._apply_domain_transform(Z_start, domain=domain, warn_s_parameter=False)
x_end, y_end = self._apply_domain_transform(Z_end, domain=domain, warn_s_parameter=False)
z_start_norm = x_start + 1j * y_start
z_end_norm = x_end + 1j * y_end
# Convert to reflection coefficients
gamma_start = utils.moebius_transform(z_start_norm, norm=1)
gamma_end = utils.moebius_transform(z_end_norm, norm=1)
mag_start = np.abs(gamma_start)
mag_end = np.abs(gamma_end)
# Check if they're on the same VSWR circle (within tolerance)
vswr_tolerance = 0.01 # 1% tolerance
same_vswr = np.abs(mag_start - mag_end) < vswr_tolerance
fmt_str = args[0] if args else None
fmt_kwargs = kwargs.copy()
if same_vswr:
# CASE 1: Same VSWR - single arc along constant-VSWR circle
avg_mag = 0.5 * (mag_start + mag_end)
angle_start = np.angle(gamma_start)
angle_end = np.angle(gamma_end)
# Find shortest arc
angle_diff = angle_end - angle_start
while angle_diff > np.pi:
angle_diff -= 2 * np.pi
while angle_diff < -np.pi:
angle_diff += 2 * np.pi
angles = np.linspace(angle_start, angle_start + angle_diff, num_points)
gamma_path = avg_mag * np.exp(1j * angles)
# Plot single arc with arrow (using R_DOMAIN for reflection coefficients)
if fmt_str:
return self.plot(gamma_path, fmt_str, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs)
return self.plot(gamma_path, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs)
# CASE 2: Different VSWR - two-step matching path
# Intermediate point: rotate to real axis on start VSWR circle
angle_start = np.angle(gamma_start)
angle_end = np.angle(gamma_end)
# Choose which real axis crossing is closer to end point
if np.abs(angle_end - 0) < np.abs(angle_end - np.pi):
angle_intermediate = 0.0 # Positive real axis
else:
angle_intermediate = np.pi # Negative real axis
gamma_intermediate = mag_start * np.exp(1j * angle_intermediate)
# STEP 1: Rotate along constant VSWR from start to real axis
angle_diff = angle_intermediate - angle_start
while angle_diff > np.pi:
angle_diff -= 2 * np.pi
while angle_diff < -np.pi:
angle_diff += 2 * np.pi
angles_step1 = np.linspace(angle_start, angle_start + angle_diff, num_points // 2)
gamma_step1 = mag_start * np.exp(1j * angles_step1)
# STEP 2: Move from intermediate point toward center (radial)
t = np.linspace(0, 1, num_points // 2)
gamma_step2 = gamma_intermediate * (1 - t) + gamma_end * t
lines = []
# Plot step 1 (transmission line rotation) with arrow
if fmt_str:
lines.extend(self.plot(gamma_step1, fmt_str, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs))
else:
lines.extend(self.plot(gamma_step1, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs))
# Plot step 2 (reactive element) with arrow - remove label to avoid duplicate
fmt_kwargs_2 = fmt_kwargs.copy()
fmt_kwargs_2.pop("label", None)
if fmt_str:
lines.extend(self.plot(gamma_step2, fmt_str, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs_2))
else:
lines.extend(self.plot(gamma_step2, domain=R_DOMAIN, arrow=arrow, **fmt_kwargs_2))
return lines