@@ -1104,7 +1104,7 @@ def gen_zero_centered_series(val_min, val_max, period):
11041104 'nyquist.arrow_size' : 8 , # pixel size for arrows
11051105 'nyquist.encirclement_threshold' : 0.05 , # warning threshold
11061106 'nyquist.indent_radius' : 1e-4 , # indentation radius
1107- 'nyquist.indent_direction' : 'right' , # indentation direction
1107+ 'nyquist.indent_direction' : None , # automatic indentation
11081108 'nyquist.indent_points' : 200 , # number of points to insert
11091109 'nyquist.max_curve_magnitude' : 15 , # rescale large values
11101110 'nyquist.blend_fraction' : 0.15 , # when to start scaling
@@ -1240,10 +1240,13 @@ def nyquist_response(
12401240 Define the threshold for generating a warning if the number of net
12411241 encirclements is a non-integer value. Default value is 0.05 and can
12421242 be set using `config.defaults['nyquist.encirclement_threshold']`.
1243- indent_direction : str, optional
1244- For poles on the imaginary axis, set the direction of indentation to
1245- be 'right' (default), 'left', or 'none'. The default value can
1246- be set using `config.defaults['nyquist.indent_direction']`.
1243+ indent_direction : str or None, optional
1244+ Direction to indent around poles within `indent_radius` of the
1245+ imaginary axis: 'right', 'left', or 'none' (disable indentation).
1246+ The default, None, indents to the right of stable poles and poles
1247+ exactly on the imaginary axis, and to the left of unstable poles.
1248+ The default value can be set using
1249+ `config.defaults['nyquist.indent_direction']`.
12471250 indent_points : int, optional
12481251 Number of points to insert in the Nyquist contour around poles that
12491252 are at or near the imaginary axis.
@@ -1277,10 +1280,18 @@ def nyquist_response(
12771280 If a continuous-time system contains poles on or near the imaginary
12781281 axis, a small indentation will be used to avoid the pole. The radius
12791282 of the indentation is given by `indent_radius` and it is taken to the
1280- right of stable poles and the left of unstable poles. If a pole is
1281- exactly on the imaginary axis, the `indent_direction` parameter can be
1282- used to set the direction of indentation. Setting `indent_direction`
1283- to 'none' will turn off indentation.
1283+ right of stable poles and the left of unstable poles by default. Poles
1284+ exactly on the imaginary axis are indented to the right. Setting
1285+ `indent_direction` to 'left' or 'right' overrides this choice for all
1286+ poles within `indent_radius` of the axis; 'none' turns off indentation.
1287+ Explicit directions can change which open-loop poles are enclosed by
1288+ the contour, and hence the number of encirclements.
1289+
1290+ When `omega` is supplied, no extra points are added for indentations.
1291+ The frequencies must resolve each detour sufficiently. Automatic
1292+ frequency selection adds `indent_points` around nearby poles. A forced
1293+ detour around the far side of an off-axis pole can change both the real
1294+ and imaginary parts of the corresponding contour points.
12841295
12851296 For those portions of the Nyquist plot in which the contour is indented
12861297 to avoid poles, resulting in a scaling of the Nyquist plot, the line
@@ -1312,8 +1323,6 @@ def nyquist_response(
13121323 # Use existing dictionary, to keep track of processed keywords
13131324 _kwargs |= kwargs
13141325
1315- indent_direction_arg = 'indent_direction' in _kwargs
1316-
13171326 # Get values for params
13181327 omega_num_given = omega_num is not None
13191328 omega_num = config ._get_param ('freqplot' , 'number_of_samples' , omega_num )
@@ -1322,11 +1331,16 @@ def nyquist_response(
13221331 encirclement_threshold = config ._get_param (
13231332 'nyquist' , 'encirclement_threshold' , _kwargs ,
13241333 _nyquist_defaults , pop = True )
1325- indent_direction = config ._get_param (
1326- 'nyquist' , 'indent_direction' , _kwargs , _nyquist_defaults , pop = True )
1334+ # Unlike most configuration parameters, None explicitly selects automatic
1335+ # indentation, even if the configured default specifies a direction.
1336+ indent_direction = _kwargs .pop ('indent_direction' , config ._get_param (
1337+ 'nyquist' , 'indent_direction' , defval = _nyquist_defaults ))
13271338 indent_points = config ._get_param (
13281339 'nyquist' , 'indent_points' , _kwargs , _nyquist_defaults , pop = True )
13291340
1341+ if indent_direction not in (None , 'left' , 'right' , 'none' ):
1342+ raise ValueError ("unknown value for indent_direction" )
1343+
13301344 if _check_kwargs and _kwargs :
13311345 raise TypeError ("unrecognized keywords: " , str (_kwargs ))
13321346
@@ -1401,43 +1415,53 @@ def nyquist_response(
14011415 #
14021416 # Check to make sure indent radius is small enough
14031417 #
1404- # If there is a closed loop pole that is near the imaginary axis
1405- # at a point that is near an open loop pole, it is possible that
1418+ # If there is a closed loop pole inside an indentation circle,
14061419 # indentation might skip or create an extraneous encirclement.
1420+ # Forced detours can reach past the imaginary-axis strip of
1421+ # width indent_radius, so check proximity to the indented poles.
14071422 # We check for that situation here and generate a warning if that
14081423 # could happen.
14091424 #
1425+ indent_poles = splane_poles [abs (splane_poles .real ) < indent_radius ]
14101426 for p_cl in splane_cl_poles :
1411- # See if any closed loop poles are near the imaginary axis
1412- if abs (p_cl .real ) <= indent_radius :
1427+ if len (indent_poles ) > 0 :
14131428 # See if any open loop poles are close to closed loop poles
1414- if len (splane_poles ) > 0 :
1415- p_ol = splane_poles [
1416- (np .abs (splane_poles - p_cl )).argmin ()]
1429+ p_ol = indent_poles [
1430+ (np .abs (indent_poles - p_cl )).argmin ()]
14171431
1418- if abs (p_ol - p_cl ) <= indent_radius and \
1419- warn_encirclements :
1420- warnings .warn (
1421- "indented contour may miss closed loop pole; "
1422- "consider reducing indent_radius to below "
1423- f"{ abs (p_ol - p_cl ):5.2g} " , stacklevel = 2 )
1432+ if abs (p_ol - p_cl ) <= indent_radius and \
1433+ warn_encirclements :
1434+ warnings .warn (
1435+ "indented contour may miss closed loop pole; "
1436+ "consider reducing indent_radius to below "
1437+ f"{ abs (p_ol - p_cl ):5.2g} " , stacklevel = 2 )
14241438
14251439 #
14261440 # See if we should add some frequency points near imaginary poles
14271441 #
14281442 for p in splane_poles :
14291443 # See if we need to process this pole (skip if on the negative
14301444 # imaginary axis or not near imaginary axis + user override)
1431- if p .imag < 0 or abs (p .real ) > indent_radius or \
1445+ if p .imag < 0 or abs (p .real ) >= indent_radius or \
14321446 omega_range_given :
14331447 continue
14341448
1449+ # Find the circle's intersections with the imaginary axis.
1450+ half_height = np .sqrt (indent_radius ** 2 - p .real ** 2 )
1451+ start_freq = max (0 , p .imag - half_height )
1452+ end_freq = p .imag + half_height
1453+ side = 1 if indent_direction == 'right' or (
1454+ indent_direction is None and p .real <= 0 ) else - 1
1455+ if side * p .real > 0 and p .imag < indent_radius :
1456+ # A major arc crossing the real axis starts there, so that
1457+ # the primary contour still joins its mirror image.
1458+ start_freq = 0
1459+
14351460 # Find the frequencies before the pole frequency
14361461 below_points = np .argwhere (
1437- splane_contour .imag - abs ( p . imag ) < - indent_radius )
1462+ splane_contour .imag < start_freq )
14381463 if below_points .size > 0 :
14391464 first_point = below_points [- 1 ].item ()
1440- start_freq = p .imag - indent_radius
14411465 else :
14421466 # Add the points starting at the beginning of the contour
14431467 assert splane_contour [0 ] == 0
@@ -1446,57 +1470,61 @@ def nyquist_response(
14461470
14471471 # Find the frequencies after the pole frequency
14481472 above_points = np .argwhere (
1449- splane_contour .imag - abs ( p . imag ) > indent_radius )
1473+ splane_contour .imag > end_freq )
14501474 last_point = above_points [0 ].item ()
14511475
14521476 # Add points for half/quarter circle around pole frequency
14531477 # (these will get indented left or right below)
14541478 splane_contour = np .concatenate ((
14551479 splane_contour [0 :first_point + 1 ],
14561480 (1j * np .linspace (
1457- start_freq , p . imag + indent_radius , indent_points )),
1481+ start_freq , end_freq , indent_points )),
14581482 splane_contour [last_point :]))
14591483
14601484 # Indent points that are too close to a pole
14611485 if len (splane_poles ) > 0 : # accommodate no splane poles if dtime sys
14621486 for i , s in enumerate (splane_contour ):
14631487 # Find the nearest pole
14641488 p = splane_poles [(np .abs (splane_poles - s )).argmin ()]
1489+ if s .imag == 0 and p .imag < 0 :
1490+ p = p .conjugate ()
14651491
1466- # See if we need to indent around it
1467- if abs (s - p ) < indent_radius :
1468- # Figure out how much to offset (simple trigonometry)
1469- offset = np .sqrt (
1470- indent_radius ** 2 - (s - p ).imag ** 2 ) \
1471- - (s - p ).real
1472-
1473- # Figure out which way to offset the contour point
1474- if indent_direction_arg :
1475- if indent_direction == 'right' :
1476- # Indent to the right
1477- splane_contour [i ] += offset
1478-
1479- elif indent_direction == 'left' :
1480- # Indent to the left
1481- splane_contour [i ] -= offset
1482-
1483- else :
1484- raise ValueError (
1485- "unknown value for indent_direction" )
1486-
1487- elif p .real < 0 or (p .real == 0 and
1488- indent_direction == 'right' ):
1489- # Indent to the right
1490- splane_contour [i ] += offset
1491-
1492- elif p .real > 0 or (p .real == 0 and
1493- indent_direction == 'left' ):
1494- # Indent to the left
1495- splane_contour [i ] -= offset
1492+ side = 1 if indent_direction == 'right' or (
1493+ indent_direction is None and p .real <= 0 ) else - 1
1494+ major_arc = side * p .real > 0
1495+ crosses_origin = major_arc and 0 <= p .imag < indent_radius
14961496
1497+ # See if we need to indent around it
1498+ if abs (p .real ) >= indent_radius :
1499+ continue
1500+ half_height = np .sqrt (indent_radius ** 2 - p .real ** 2 )
1501+ if abs (s - p ) < indent_radius or (
1502+ crosses_origin and
1503+ 0 <= s .imag <= p .imag + half_height ):
1504+ if major_arc :
1505+ # The far side of an off-axis pole requires a major
1506+ # arc to join the imaginary axis continuously.
1507+ angle = np .arccos (- side * p .real / indent_radius )
1508+ start_freq = max (0 , p .imag - half_height )
1509+ start_angle = - angle
1510+ if crosses_origin :
1511+ start_freq = 0
1512+ start_angle = - np .arcsin (
1513+ p .imag / indent_radius )
1514+ fraction = (s .imag - start_freq ) / (
1515+ p .imag + half_height - start_freq )
1516+ theta = start_angle + fraction * (
1517+ angle - start_angle )
1518+ splane_contour [i ] = p + indent_radius * (
1519+ side * np .cos (theta ) + 1j * np .sin (theta ))
1520+ if s .imag == 0 :
1521+ splane_contour [i ] = splane_contour [i ].real
14971522 else :
1498- raise ValueError (
1499- "unknown value for indent_direction" )
1523+ # Preserve the frequencies on the near-side arc.
1524+ offset = np .sqrt (
1525+ indent_radius ** 2 - (s - p ).imag ** 2 )
1526+ splane_contour [i ] = (
1527+ p .real + side * offset + 1j * s .imag )
15001528
15011529 # change contour to z-plane if necessary
15021530 if sys .isctime ():
@@ -1536,18 +1564,22 @@ def nyquist_response(
15361564 if isinstance (sys , (StateSpace , TransferFunction )):
15371565 # Count the number of open/closed loop RHP poles
15381566 if sys .isctime ():
1539- if indent_direction == 'right' :
1540- P = (sys .poles ().real > 0 ).sum ()
1541- else :
1542- P = (sys .poles ().real >= 0 ).sum ()
1567+ P = (sys .poles ().real >= 0 ).sum ()
15431568 Z = (sys .feedback ().poles ().real >= 0 ).sum ()
15441569 else :
1545- if indent_direction == 'right' :
1546- P = (np .abs (sys .poles ()) > 1 ).sum ()
1547- else :
1548- P = (np .abs (sys .poles ()) >= 1 ).sum ()
1570+ P = (np .abs (sys .poles ()) >= 1 ).sum ()
15491571 Z = (np .abs (sys .feedback ().poles ()) >= 1 ).sum ()
15501572
1573+ if indent_direction != 'none' :
1574+ # Explicit directions can include stable poles or exclude
1575+ # unstable poles. Use the mapped poles for discrete systems
1576+ # so the pole count and the contour use the same boundary.
1577+ enclosed = splane_poles .real > 0
1578+ if indent_direction is not None :
1579+ enclosed [abs (splane_poles .real ) < indent_radius ] = (
1580+ indent_direction == 'left' )
1581+ P = enclosed .sum ()
1582+
15511583 # Check to make sure the results make sense; warn if not
15521584 if Z != count + P and warn_encirclements :
15531585 warnings .warn (
@@ -1662,9 +1694,10 @@ def nyquist_plot(
16621694 Define the threshold for generating a warning if the number of net
16631695 encirclements is a non-integer value. Default value is 0.05 and can
16641696 be set using `config.defaults['nyquist.encirclement_threshold']`.
1665- indent_direction : str, optional
1666- For poles on the imaginary axis, set the direction of indentation to
1667- be 'right' (default), 'left', or 'none'.
1697+ indent_direction : str or None, optional
1698+ Direction to indent around poles on or near the imaginary axis:
1699+ 'right', 'left', or 'none' (disable indentation). The default, None,
1700+ selects the side automatically; see `nyquist_response`.
16681701 indent_points : int, optional
16691702 Number of points to insert in the Nyquist contour around poles that
16701703 are at or near the imaginary axis.
@@ -1761,11 +1794,13 @@ def nyquist_plot(
17611794 If a continuous-time system contains poles on or near the imaginary
17621795 axis, a small indentation will be used to avoid the pole. The radius
17631796 of the indentation is given by `indent_radius` and it is taken to the
1764- right of stable poles and the left of unstable poles. If a pole is
1765- exactly on the imaginary axis, the `indent_direction` parameter can be
1766- used to set the direction of indentation. Setting `indent_direction`
1767- to 'none' will turn off indentation. If `return_contour` is True,
1768- the exact contour used for evaluation is returned.
1797+ right of stable poles and the left of unstable poles by default. Poles
1798+ exactly on the imaginary axis are indented to the right. Setting
1799+ `indent_direction` to 'left' or 'right' overrides this choice for all
1800+ poles within `indent_radius` of the axis; 'none' turns off indentation.
1801+ Explicit directions can change which open-loop poles are enclosed by
1802+ the contour, and hence the number of encirclements. If `return_contour`
1803+ is True, the exact contour used for evaluation is returned.
17691804
17701805 For those portions of the Nyquist plot in which the contour is indented
17711806 to avoid poles, resulting in a scaling of the Nyquist plot, the line
0 commit comments