From 747935398e78fa83e17aa7d4085a449dcedea27c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 6 Nov 2024 08:23:37 -0800 Subject: [PATCH 1/8] create find_operating_point with updated documentation --- control/nlsys.py | 105 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index accd24c0f..768dfa00a 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -13,7 +13,7 @@ :class:`~control.NonlinearIOSystem` class that represents (possibly nonlinear) input/output systems. The :class:`~control.NonlinearIOSystem` class is a general class that defines any continuous or discrete time dynamical system. -Input/output systems can be simulated and also used to compute equilibrium +Input/output systems can be simulated and also used to compute operating points and linearizations. """ @@ -1663,69 +1663,98 @@ def ivp_rhs(t, x): success=soln.success, message=message) -def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, - iu=None, iy=None, ix=None, idx=None, dx0=None, - return_y=False, return_result=False): - """Find the equilibrium point for an input/output system. +def find_operating_point( + sys, x0, u0=None, y0=None, t=0, params=None, + iu=None, iy=None, ix=None, idx=None, dx0=None, root_method=None, + root_kwargs=None, return_y=False, return_result=False): + """Find an operating point for an input/output system. - Returns the value of an equilibrium point given the initial state and - either input value or desired output value for the equilibrium point. + An operating point for a nonlinear system is a state `xop` and input + `uop` around which a nonlinear system operates. This point is most + commonly an equilibrium point for the system, but in some cases a + non-equilibrium operating point can be used. + + This function attempts to find an operating point given a specification + for the desired inputs, outputs, states, or state updates of the system. + + In its simplest form, `find_operating_point` finds an equilibrium point + given either the desired input or desired output: + + xeq, ueq = find_operating_point(sys, x0, u0) + xeq, ueq = find_operating_point(sys, x0, u0, y0) + + The first form finds an equilibrium point for a given input u0 based on + an initial guess x0. The second form fixes the desired output values + and uses x0 and u0 as an initial guess to find the equilibrium point. + If no equilibrium point can be found, the function returns the + operating point that minimizes the state update (state derivative for + continuous time systems, state difference for discrete time systems). + + More complex operating points can be found by specifying which states, + inputs, or outputs should be used in computing the operating point, as + well as desired values of the states, inputs, outputs, or state + updates. Parameters ---------- sys : NonlinearIOSystem - I/O system for which the equilibrium point is sought. + I/O system for which the operating point is sought. x0 : list of initial state values - Initial guess for the value of the state near the equilibrium point. + Initial guess for the value of the state near the operating point. u0 : list of input values, optional - If `y0` is not specified, sets the equilibrium value of the input. If - `y0` is given, provides an initial guess for the value of the input. - Can be omitted if the system does not have any inputs. + If `y0` is not specified, sets the value of the input. If `y0` is + given, provides an initial guess for the value of the input. Can + be omitted if the system does not have any inputs. y0 : list of output values, optional If specified, sets the desired values of the outputs at the - equilibrium point. + operating point. t : float, optional - Evaluation time, for time-varying systems + Evaluation time, for time-varying systems. params : dict, optional Parameter values for the system. Passed to the evaluation functions for the system as default values, overriding internal defaults. iu : list of input indices, optional If specified, only the inputs with the given indices will be fixed at - the specified values in solving for an equilibrium point. All other + the specified values in solving for an operating point. All other inputs will be varied. Input indices can be listed in any order. iy : list of output indices, optional If specified, only the outputs with the given indices will be fixed at - the specified values in solving for an equilibrium point. All other + the specified values in solving for an operating point. All other outputs will be varied. Output indices can be listed in any order. ix : list of state indices, optional If specified, states with the given indices will be fixed at the - specified values in solving for an equilibrium point. All other + specified values in solving for an operating point. All other states will be varied. State indices can be listed in any order. dx0 : list of update values, optional If specified, the value of update map must match the listed value - instead of the default value of 0. + instead of the default value for an equilibrium point. idx : list of state indices, optional If specified, state updates with the given indices will have their update maps fixed at the values given in `dx0`. All other update - values will be ignored in solving for an equilibrium point. State + values will be ignored in solving for an operating point. State indices can be listed in any order. By default, all updates will be - fixed at `dx0` in searching for an equilibrium point. + fixed at `dx0` in searching for an operating point. + root_method : str, optonal + Method to find the operating point. If specified, this parameter + is passed to the :func:`scipy.optimize.root` function. + root_kwargs : dict, optional + Additional keyword arguments to pass :func:`scipy.optimize.root`. return_y : bool, optional - If True, return the value of output at the equilibrium point. + If True, return the value of output at the operating point. return_result : bool, optional If True, return the `result` option from the - :func:`scipy.optimize.root` function used to compute the equilibrium - point. + :func:`scipy.optimize.root` function used to compute the + operating point. Returns ------- - xeq : array of states + xop : array of states Value of the states at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. - ueq : array of input values + uop : array of input values Value of the inputs at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. - yeq : array of output values, optional + yop : array of output values, optional If `return_y` is True, returns the value of the outputs at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. @@ -1744,6 +1773,11 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, """ from scipy.optimize import root + # Process arguments for the root function + root_kwargs = dict() if root_kwargs is None else root_kwargs + if root_method: + root_kwargs['method'] = root_method + # Figure out the number of states, inputs, and outputs x0, nstates = _process_vector_argument(x0, "x0", sys.nstates) u0, ninputs = _process_vector_argument(u0, "u0", sys.ninputs) @@ -1769,7 +1803,7 @@ def state_rhs(z): return sys._rhs(t, z, u0) - z else: def state_rhs(z): return sys._rhs(t, z, u0) - result = root(state_rhs, x0) + result = root(state_rhs, x0, **root_kwargs) z = (result.x, u0, sys._out(t, result.x, u0)) else: @@ -1786,9 +1820,10 @@ def rootfun(z): return np.concatenate( (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0) - z0 = np.concatenate((x0, u0), axis=0) # Put variables together - result = root(rootfun, z0) # Find the eq point - x, u = np.split(result.x, [nstates]) # Split result back in two + # Find roots with (x, u) as free variables + z0 = np.concatenate((x0, u0), axis=0) + result = root(rootfun, z0, **root_kwargs) + x, u = np.split(result.x, [nstates]) z = (x, u, sys._out(t, x, u)) else: @@ -1903,7 +1938,7 @@ def rootfun(z): z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0) # Finally, call the root finding function - result = root(rootfun, z0) + result = root(rootfun, z0, **root_kwargs) # Extract out the results and insert into x and u x[state_vars] = result.x[:nstate_vars] @@ -2509,7 +2544,7 @@ def _find_output_or_input_signal(spec): (syslist[isys].name, syslist[isys].input_labels[isig], gain)) return signal_list - + if isinstance(connection, list): # Passed a list => create input map dprint(f" detected output list") @@ -2519,7 +2554,7 @@ def _find_output_or_input_signal(spec): new_outlist.append(signal_list) else: new_outlist += _find_output_or_input_signal(connection) - + outlist, outputs = new_outlist, new_outputs dprint(f" {outlist=}\n {outputs=}") @@ -2679,3 +2714,7 @@ def connection_table(sys, show_names=False, column_width=32): "an InterconnectedSystem." sys.connection_table(show_names=show_names, column_width=column_width) + + +# Short versions of function call +find_eqpt = find_operating_point From fa162ece64fc79ac6ac8f79ba45e80ff5c8aa719 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 6 Nov 2024 14:08:37 -0800 Subject: [PATCH 2/8] add OperatingPoint object + unit test --- control/nlsys.py | 65 ++++++++++++++++++++++++++++++++++++- control/tests/iosys_test.py | 36 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/control/nlsys.py b/control/nlsys.py index 768dfa00a..33d9379c7 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -32,7 +32,7 @@ __all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys', 'input_output_response', 'find_eqpt', 'linearize', - 'interconnect', 'connection_table'] + 'interconnect', 'connection_table', 'find_operating_point'] class NonlinearIOSystem(InputOutputSystem): @@ -1663,6 +1663,60 @@ def ivp_rhs(t, x): success=soln.success, message=message) +class OperatingPoint(object): + """A class for representing the operating point of a nonlinear I/O system. + + The ``OperatingPoint`` class stores the operating point of a nonlinear + system, which consists of the state and input for a nonlinear system. + The main use for this class is as the return object for the + :func:`find_operating_point` function. + + Attributes + ---------- + xop : array + State vector at the operating point. + uop : array + Input vector at the operating point. + result : :class:`scipy.optimize.OptimizeResult`, optional + Result from the :func:`scipy.optimize.root` function, if available. + + """ + def __init__( + self, xop, uop=None, yop=None, result=None, + return_y=False, return_result=False): + self.xop = xop + self.uop = uop + + if yop is None and return_y and not return_result: + raise SystemError("return_y specified by no y0 value") + self.yop = yop + self.return_y = return_y + + if result is None and return_result: + raise SystemError("return_result specified by no result value") + self.result = result + self.return_result = return_result + + # Implement iter to allow assigning to a tuple + def __iter__(self): + if self.return_y and self.return_result: + return iter((self.xop, self.uop, self.yop, self.result)) + elif self.return_y: + return iter((self.xop, self.uop, self.yop)) + elif self.return_result: + return iter((self.xop, self.uop, self.result)) + else: + return iter((self.xop, self.uop)) + + # Implement (thin) getitem to allow access via legacy indexing + def __getitem__(self, index): + return list(self.__iter__())[index] + + # Implement (thin) len to emulate legacy return value + def __len__(self): + return len(list(self.__iter__())) + + def find_operating_point( sys, x0, u0=None, y0=None, t=0, params=None, iu=None, iy=None, ix=None, idx=None, dx0=None, root_method=None, @@ -1946,6 +2000,15 @@ def rootfun(z): z = (x, u, sys._out(t, x, u)) # Return the result based on what the user wants and what we found + if return_result or result.success: + return OperatingPoint( + z[0], z[1], z[2], result, return_y, return_result) + else: + # Something went wrong, don't return anything + return OperatingPoint( + None, None, None, result, return_y, return_result) + + # TODO: remove code when ready if not return_y: z = z[0:2] # Strip y from result if not desired if return_result: diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index dd30ea71e..beca8d445 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -2087,6 +2087,42 @@ def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect): np.testing.assert_allclose(np.array(ueq), u_expect, atol=1e-6) +# Test out new operating point version of find_eqpt +def test_find_operating_point(): + dt = 1 + sys = ct.NonlinearIOSystem( + eqpt_rhs, eqpt_out, dt=dt, states=3, inputs=2, outputs=2) + + # Conditions that lead to no exact solution (from previous unit test) + x0 = 0; ix = None + u0 = [-1, 0]; iu = None + y0 = None; iy = None + dx0 = None; idx = None + + # Default version: no equilibrium solution => returns None + op_point = ct.find_operating_point( + sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx) + assert op_point.xop is None + assert op_point.uop is None + assert op_point.result.success is False + + # Change the method to Levenberg-Marquardt (gives nearest point) + op_point = ct.find_operating_point( + sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx, + root_method='lm') + assert op_point.xop is not None + assert op_point.uop is not None + assert op_point.result.success is True + + # Make sure we get a solution if we ask for the result explicitly + op_point = ct.find_operating_point( + sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx, + return_result=True) + assert op_point.xop is not None + assert op_point.uop is not None + assert op_point.result.success is False + + def test_iosys_sample(): csys = ct.rss(2, 1, 1) dsys = csys.sample(0.1) From 223acc64f92e7119a587c93f7e62ebc3a949973d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 6 Nov 2024 21:31:46 -0800 Subject: [PATCH 3/8] allow OperatingPoint for linearize + code and documentation tweaks --- control/matlab/__init__.py | 2 ++ control/nlsys.py | 52 ++++++++++++++++++++++--------------- control/tests/iosys_test.py | 32 ++++++++++++++++++----- doc/control.rst | 2 +- doc/iosys.rst | 18 ++++++------- 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index b02d16d53..98e6babc7 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -84,10 +84,12 @@ from ..dtime import c2d from ..sisotool import sisotool from ..stochsys import lqe, dlqe +from ..nlsys import find_operating_point # Functions that are renamed in MATLAB pole, zero = poles, zeros freqresp = frequency_response +trim = find_operating_point # Import functions specific to Matlab compatibility package from .timeresp import * diff --git a/control/nlsys.py b/control/nlsys.py index 33d9379c7..761525d4d 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -515,7 +515,7 @@ def feedback(self, other=1, sign=-1, params=None): # Return the newly created system return newsys - def linearize(self, x0, u0, t=0, params=None, eps=1e-6, + def linearize(self, x0, u0=None, t=0, params=None, eps=1e-6, copy_names=False, **kwargs): """Linearize an input/output system at a given state and input. @@ -526,6 +526,14 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, """ from .statesp import StateSpace + # Allow first argument to be an operating point + if isinstance(x0, OperatingPoint): + if u0 is None: + u0 = x0.inputs + x0 = x0.states + elif u0 is None: + u0 = 0 + # # If the linearization is not defined by the subclass, perform a # numerical linearization use the `_rhs()` and `_out()` member @@ -1673,19 +1681,19 @@ class OperatingPoint(object): Attributes ---------- - xop : array + states : array State vector at the operating point. - uop : array + inputs : array Input vector at the operating point. result : :class:`scipy.optimize.OptimizeResult`, optional Result from the :func:`scipy.optimize.root` function, if available. """ def __init__( - self, xop, uop=None, yop=None, result=None, + self, states, inputs=None, yop=None, result=None, return_y=False, return_result=False): - self.xop = xop - self.uop = uop + self.states = states + self.inputs = inputs if yop is None and return_y and not return_result: raise SystemError("return_y specified by no y0 value") @@ -1700,13 +1708,13 @@ def __init__( # Implement iter to allow assigning to a tuple def __iter__(self): if self.return_y and self.return_result: - return iter((self.xop, self.uop, self.yop, self.result)) + return iter((self.states, self.inputs, self.yop, self.result)) elif self.return_y: - return iter((self.xop, self.uop, self.yop)) + return iter((self.states, self.inputs, self.yop)) elif self.return_result: - return iter((self.xop, self.uop, self.result)) + return iter((self.states, self.inputs, self.result)) else: - return iter((self.xop, self.uop)) + return iter((self.states, self.inputs)) # Implement (thin) getitem to allow access via legacy indexing def __getitem__(self, index): @@ -1723,10 +1731,10 @@ def find_operating_point( root_kwargs=None, return_y=False, return_result=False): """Find an operating point for an input/output system. - An operating point for a nonlinear system is a state `xop` and input - `uop` around which a nonlinear system operates. This point is most - commonly an equilibrium point for the system, but in some cases a - non-equilibrium operating point can be used. + An operating point for a nonlinear system is a state and input around + which a nonlinear system operates. This point is most commonly an + equilibrium point for the system, but in some cases a non-equilibrium + operating point can be used. This function attempts to find an operating point given a specification for the desired inputs, outputs, states, or state updates of the system. @@ -1802,10 +1810,10 @@ def find_operating_point( Returns ------- - xop : array of states + states : array of states Value of the states at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. - uop : array of input values + inputs : array of input values Value of the inputs at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. yop : array of output values, optional @@ -2034,12 +2042,13 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): ---------- sys : InputOutputSystem The system to be linearized. - xeq : array - The state at which the linearization will be evaluated (does not need - to be an equilibrium state). - ueq : array + xeq : array or :class:`~control.OperatingPoint` + The state or operating point at which the linearization will be + evaluated (does not need to be an equilibrium state). + ueq : array, optional The input at which the linearization will be evaluated (does not need - to correspond to an equlibrium state). + to correspond to an equlibrium state). Can be omitted if `xeq` is + an :class:`~control.OperatingPoint`. Defaults to 0. t : float, optional The time at which the linearization will be computed (for time-varying systems). @@ -2074,6 +2083,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): Description of the system outputs. Same format as `inputs`. states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. + """ if not isinstance(sys, InputOutputSystem): raise TypeError("Can only linearize InputOutputSystem types") diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index beca8d445..608fdc58c 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -2102,27 +2102,47 @@ def test_find_operating_point(): # Default version: no equilibrium solution => returns None op_point = ct.find_operating_point( sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx) - assert op_point.xop is None - assert op_point.uop is None + assert op_point.states is None + assert op_point.inputs is None assert op_point.result.success is False # Change the method to Levenberg-Marquardt (gives nearest point) op_point = ct.find_operating_point( sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx, root_method='lm') - assert op_point.xop is not None - assert op_point.uop is not None + assert op_point.states is not None + assert op_point.inputs is not None assert op_point.result.success is True # Make sure we get a solution if we ask for the result explicitly op_point = ct.find_operating_point( sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx, return_result=True) - assert op_point.xop is not None - assert op_point.uop is not None + assert op_point.states is not None + assert op_point.inputs is not None assert op_point.result.success is False +def test_operating_point(): + dt = 1 + sys = ct.NonlinearIOSystem( + eqpt_rhs, eqpt_out, dt=dt, states=3, inputs=2, outputs=2) + + # Find the operating point near the origin + op_point = ct.find_operating_point(sys, 0, 0) + + # Linearize the old fashioned way + linsys_orig = ct.linearize(sys, op_point.states, op_point.inputs) + + # Linearize around the operating point + linsys_oppt = ct.linearize(sys, op_point) + + np.testing.assert_allclose(linsys_orig.A, linsys_oppt.A) + np.testing.assert_allclose(linsys_orig.B, linsys_oppt.B) + np.testing.assert_allclose(linsys_orig.C, linsys_oppt.C) + np.testing.assert_allclose(linsys_orig.D, linsys_oppt.D) + + def test_iosys_sample(): csys = ct.rss(2, 1, 1) dsys = csys.sample(0.1) diff --git a/doc/control.rst b/doc/control.rst index 1544f93d0..dd418f2af 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -144,7 +144,7 @@ Nonlinear system support :toctree: generated/ describing_function - find_eqpt + find_operating_point linearize input_output_response summing_junction diff --git a/doc/iosys.rst b/doc/iosys.rst index 9008a0e56..db504c0ab 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -16,12 +16,13 @@ function:: resp = ct.input_output_response(io_sys, T, U, X0, params) t, y, x = resp.time, resp.outputs, resp.states -An input/output system can be linearized around an equilibrium point to obtain -a :class:`~control.StateSpace` linear system. Use the -:func:`~control.find_eqpt` function to obtain an equilibrium point and the -:func:`~control.linearize` function to linearize about that equilibrium point:: +An input/output system can be linearized around an equilibrium point +to obtain a :class:`~control.StateSpace` linear system. Use the +:func:`~control.find_operating_point` function to obtain an +equilibrium point and the :func:`~control.linearize` function to +linearize about that equilibrium point:: - xeq, ueq = ct.find_eqpt(io_sys, X0, U0) + xeq, ueq = ct.find_operating_point(io_sys, X0, U0) ss_sys = ct.linearize(io_sys, xeq, ueq) Input/output systems are automatically created for state space LTI systems @@ -123,9 +124,8 @@ system and computing the linearization about that point. .. code-block:: python - eqpt = ct.find_eqpt(io_predprey, X0, 0) - xeq = eqpt[0] # choose the nonzero equilibrium point - lin_predprey = ct.linearize(io_predprey, xeq, 0) + eqpt = ct.find_operating_point(io_predprey, X0, 0) + lin_predprey = ct.linearize(io_predprey, eqpt) We next compute a controller that stabilizes the equilibrium point using eigenvalue placement and computing the feedforward gain using the number of @@ -548,7 +548,7 @@ Module classes and functions .. autosummary:: :toctree: generated/ - ~control.find_eqpt + ~control.find_operating_point ~control.interconnect ~control.input_output_response ~control.linearize From 5eaee668fe3166318a1907481f95c02afe444e2c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 7 Nov 2024 19:26:43 -0800 Subject: [PATCH 4/8] updated OperatingPoint attribute names + docstring edits --- control/nlsys.py | 64 ++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index 761525d4d..0af79c8d8 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -519,26 +519,25 @@ def linearize(self, x0, u0=None, t=0, params=None, eps=1e-6, copy_names=False, **kwargs): """Linearize an input/output system at a given state and input. - Return the linearization of an input/output system at a given state - and input value as a StateSpace system. See - :func:`~control.linearize` for complete documentation. + Return the linearization of an input/output system at a given + operating point (or state and input value) as a StateSpace system. + See :func:`~control.linearize` for complete documentation. """ + # + # Default method: if the linearization is not defined by the + # subclass, perform a numerical linearization use the `_rhs()` and + # `_out()` member functions. + # from .statesp import StateSpace # Allow first argument to be an operating point if isinstance(x0, OperatingPoint): - if u0 is None: - u0 = x0.inputs + u0 = x0.inputs if u0 is None else u0 x0 = x0.states elif u0 is None: u0 = 0 - # - # If the linearization is not defined by the subclass, perform a - # numerical linearization use the `_rhs()` and `_out()` member - # functions. - # # Process nominal states and inputs x0, nstates = _process_vector_argument(x0, "x0", self.nstates) u0, ninputs = _process_vector_argument(u0, "u0", self.ninputs) @@ -1675,9 +1674,10 @@ class OperatingPoint(object): """A class for representing the operating point of a nonlinear I/O system. The ``OperatingPoint`` class stores the operating point of a nonlinear - system, which consists of the state and input for a nonlinear system. - The main use for this class is as the return object for the - :func:`find_operating_point` function. + system, consisting of the state and input vectors for the system. The + main use for this class is as the return object for the + :func:`find_operating_point` function and as an input to the + :func:`linearize` function. Attributes ---------- @@ -1690,14 +1690,14 @@ class OperatingPoint(object): """ def __init__( - self, states, inputs=None, yop=None, result=None, + self, states, inputs=None, outputs=None, result=None, return_y=False, return_result=False): self.states = states self.inputs = inputs - if yop is None and return_y and not return_result: + if outputs is None and return_y and not return_result: raise SystemError("return_y specified by no y0 value") - self.yop = yop + self.outputs = outputs self.return_y = return_y if result is None and return_result: @@ -1708,9 +1708,9 @@ def __init__( # Implement iter to allow assigning to a tuple def __iter__(self): if self.return_y and self.return_result: - return iter((self.states, self.inputs, self.yop, self.result)) + return iter((self.states, self.inputs, self.outputs, self.result)) elif self.return_y: - return iter((self.states, self.inputs, self.yop)) + return iter((self.states, self.inputs, self.outputs)) elif self.return_result: return iter((self.states, self.inputs, self.result)) else: @@ -1816,7 +1816,7 @@ def find_operating_point( inputs : array of input values Value of the inputs at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. - yop : array of output values, optional + outputs : array of output values, optional If `return_y` is True, returns the value of the outputs at the equilibrium point, or `None` if no equilibrium point was found and `return_result` was False. @@ -1826,11 +1826,21 @@ def find_operating_point( Notes ----- - For continuous time systems, equilibrium points are defined as points for - which the right hand side of the differential equation is zero: - :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium points - are defined as points for which the right hand side of the difference - equation returns the current state: :math:`f(t, x_e, u_e) = x_e`. + For continuous time systems, equilibrium points are defined as points + for which the right hand side of the differential equation is zero: + :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium + points are defined as points for which the right hand side of the + difference equation returns the current state: :math:`f(t, x_e, u_e) = + x_e`. + + Operating points are found using the :func:`scipy.optimize.root` + function, which will attempt to find states and inputs that satisfy the + specified constraints. If no solution is found and `return_result` is + `False`, the returned state and input for the operating point will be + `None`. If `return_result` is `True`, then the return values from + :func:`scipy.optimize.root` will be returned (but may not be valid). + If `root_method` is set to `lm`, then the least squares solution (in + the free variables) will be returned. """ from scipy.optimize import root @@ -2034,9 +2044,9 @@ def rootfun(z): def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): """Linearize an input/output system at a given state and input. - This function computes the linearization of an input/output system at a - given state and input value and returns a :class:`~control.StateSpace` - object. The evaluation point need not be an equilibrium point. + Compute the linearization of an I/O system at an operating point (state + and input) and returns a :class:`~control.StateSpace` object. The + operating point need not be an equilibrium point. Parameters ---------- From aed5706b5bf9ae47a2743a1c6ba476c05eb49473 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 7 Nov 2024 22:06:08 -0800 Subject: [PATCH 5/8] clean up docstrings, examples --- control/nlsys.py | 37 ++++++++++++++++++------------------- control/phaseplot.py | 5 +++-- control/tests/iosys_test.py | 4 ++++ doc/iosys.rst | 1 + examples/cruise-control.py | 4 ++-- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index 0af79c8d8..cd5acbf9a 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -32,7 +32,8 @@ __all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys', 'input_output_response', 'find_eqpt', 'linearize', - 'interconnect', 'connection_table', 'find_operating_point'] + 'interconnect', 'connection_table', 'OperatingPoint', + 'find_operating_point'] class NonlinearIOSystem(InputOutputSystem): @@ -92,7 +93,7 @@ class NonlinearIOSystem(InputOutputSystem): generic name is generated with a unique integer id. params : dict, optional - Parameter values for the system. Passed to the evaluation functions + Parameter values for the system. Passed to the evaluation functions for the system as default values, overriding internal defaults. See Also @@ -1685,6 +1686,8 @@ class OperatingPoint(object): State vector at the operating point. inputs : array Input vector at the operating point. + outputs : array, optional + Output vector at the operating point. result : :class:`scipy.optimize.OptimizeResult`, optional Result from the :func:`scipy.optimize.root` function, if available. @@ -1740,7 +1743,7 @@ def find_operating_point( for the desired inputs, outputs, states, or state updates of the system. In its simplest form, `find_operating_point` finds an equilibrium point - given either the desired input or desired output: + given either the desired input or desired output:: xeq, ueq = find_operating_point(sys, x0, u0) xeq, ueq = find_operating_point(sys, x0, u0, y0) @@ -1810,19 +1813,15 @@ def find_operating_point( Returns ------- - states : array of states - Value of the states at the equilibrium point, or `None` if no - equilibrium point was found and `return_result` was False. - inputs : array of input values - Value of the inputs at the equilibrium point, or `None` if no - equilibrium point was found and `return_result` was False. - outputs : array of output values, optional - If `return_y` is True, returns the value of the outputs at the - equilibrium point, or `None` if no equilibrium point was found and - `return_result` was False. - result : :class:`scipy.optimize.OptimizeResult`, optional - If `return_result` is True, returns the `result` from the - :func:`scipy.optimize.root` function. + op_point : OperatingPoint + The solution represented as an `OperatingPoint` object. The main + attributes are `states` and `inputs`, which represent the state + and input arrays at the operating point. See + :class:`OperatingPoint` for a description of other attributes. + + If accessed as a tuple, returns `states`, `inputs`, and optionally + `outputs` and `result` based on the `return_y` and `return_result` + parameters. Notes ----- @@ -1839,7 +1838,7 @@ def find_operating_point( `False`, the returned state and input for the operating point will be `None`. If `return_result` is `True`, then the return values from :func:`scipy.optimize.root` will be returned (but may not be valid). - If `root_method` is set to `lm`, then the least squares solution (in + If `root_method` is set to 'lm', then the least squares solution (in the free variables) will be returned. """ @@ -1956,8 +1955,8 @@ def rootfun(z): # * output_vars: indices of outputs that must be constrained # # This index lists can all be precomputed based on the `iu`, `iy`, - # `ix`, and `idx` lists that were passed as arguments to `find_eqpt` - # and were processed above. + # `ix`, and `idx` lists that were passed as arguments to + # `find_operating_point` and were processed above. # Get the states and inputs that were not listed as fixed state_vars = (range(nstates) if not len(ix) diff --git a/control/phaseplot.py b/control/phaseplot.py index abc050ffe..6f7ed355f 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -39,7 +39,8 @@ from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \ _process_ax_keyword, _update_plot_title from .exception import ControlNotImplemented -from .nlsys import NonlinearIOSystem, find_eqpt, input_output_response +from .nlsys import NonlinearIOSystem, find_operating_point, \ + input_output_response __all__ = ['phase_plane_plot', 'phase_plot', 'box_grid'] @@ -853,7 +854,7 @@ def _find_equilpts(sys, points, params=None): equilpts = [] for i, x0 in enumerate(points): # Look for an equilibrium point near this point - xeq, ueq = find_eqpt(sys, x0, 0, params=params) + xeq, ueq = find_operating_point(sys, x0, 0, params=params) if xeq is None: continue # didn't find anything diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 608fdc58c..09f5596ec 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -2142,6 +2142,10 @@ def test_operating_point(): np.testing.assert_allclose(linsys_orig.C, linsys_oppt.C) np.testing.assert_allclose(linsys_orig.D, linsys_oppt.D) + # Call find_operating point with method and keyword arguments + op_point = ct.find_operating_point( + sys, 0, 0, root_method='lm', root_kwargs={'tol': 1e-6}) + def test_iosys_sample(): csys = ct.rss(2, 1, 1) diff --git a/doc/iosys.rst b/doc/iosys.rst index db504c0ab..f2dfbff4d 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -544,6 +544,7 @@ Module classes and functions ~control.InterconnectedSystem ~control.LinearICSystem ~control.NonlinearIOSystem + ~control.OperatingPoint .. autosummary:: :toctree: generated/ diff --git a/examples/cruise-control.py b/examples/cruise-control.py index 7c2e562a1..b8a91eeab 100644 --- a/examples/cruise-control.py +++ b/examples/cruise-control.py @@ -165,7 +165,7 @@ def motor_torque(omega, params={}): for m in (1200, 1600, 2000): # Compute the equilibrium state for the system - X0, U0 = ct.find_eqpt( + X0, U0 = ct.find_operating_point( cruise_tf, [0, vref[0]], [vref[0], gear[0], theta0[0]], iu=[1, 2], y0=[vref[0], 0], iy=[0], params={'m': m}) @@ -347,7 +347,7 @@ def cruise_plot(sys, t, y, label=None, t_hill=None, vref=20, antiwindup=False, # Compute the equilibrium throttle setting for the desired speed (solve for x # and u given the gear, slope, and desired output velocity) -X0, U0, Y0 = ct.find_eqpt( +X0, U0, Y0 = ct.find_operating_point( cruise_pi, [vref[0], 0], [vref[0], gear[0], theta0[0]], y0=[0, vref[0]], iu=[1, 2], iy=[1], return_y=True) From 1a2770c9c34295c5928d197f80e0fed85495aae5 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 8 Nov 2024 20:55:17 -0800 Subject: [PATCH 6/8] return_y -> return_outputs + related updates --- control/nlsys.py | 40 ++++++++++++++++++-------------- control/tests/docstrings_test.py | 1 + control/tests/iosys_test.py | 40 ++++++++++++++++++++++++++++++-- control/tests/kwargs_test.py | 3 +++ 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index cd5acbf9a..d463ea7fc 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -1694,14 +1694,14 @@ class OperatingPoint(object): """ def __init__( self, states, inputs=None, outputs=None, result=None, - return_y=False, return_result=False): + return_outputs=False, return_result=False): self.states = states self.inputs = inputs - if outputs is None and return_y and not return_result: - raise SystemError("return_y specified by no y0 value") + if outputs is None and return_outputs and not return_result: + raise SystemError("return_outputs specified by no y0 value") self.outputs = outputs - self.return_y = return_y + self.return_outputs = return_outputs if result is None and return_result: raise SystemError("return_result specified by no result value") @@ -1710,9 +1710,9 @@ def __init__( # Implement iter to allow assigning to a tuple def __iter__(self): - if self.return_y and self.return_result: + if self.return_outputs and self.return_result: return iter((self.states, self.inputs, self.outputs, self.result)) - elif self.return_y: + elif self.return_outputs: return iter((self.states, self.inputs, self.outputs)) elif self.return_result: return iter((self.states, self.inputs, self.result)) @@ -1729,9 +1729,9 @@ def __len__(self): def find_operating_point( - sys, x0, u0=None, y0=None, t=0, params=None, - iu=None, iy=None, ix=None, idx=None, dx0=None, root_method=None, - root_kwargs=None, return_y=False, return_result=False): + sys, x0, u0=None, y0=None, t=0, params=None, iu=None, iy=None, + ix=None, idx=None, dx0=None, root_method=None, root_kwargs=None, + return_outputs=None, return_result=None, **kwargs): """Find an operating point for an input/output system. An operating point for a nonlinear system is a state and input around @@ -1804,8 +1804,8 @@ def find_operating_point( is passed to the :func:`scipy.optimize.root` function. root_kwargs : dict, optional Additional keyword arguments to pass :func:`scipy.optimize.root`. - return_y : bool, optional - If True, return the value of output at the operating point. + return_outputs : bool, optional + If True, return the value of outputs at the operating point. return_result : bool, optional If True, return the `result` option from the :func:`scipy.optimize.root` function used to compute the @@ -1820,8 +1820,8 @@ def find_operating_point( :class:`OperatingPoint` for a description of other attributes. If accessed as a tuple, returns `states`, `inputs`, and optionally - `outputs` and `result` based on the `return_y` and `return_result` - parameters. + `outputs` and `result` based on the `return_outputs` and + `return_result` parameters. Notes ----- @@ -1844,6 +1844,12 @@ def find_operating_point( """ from scipy.optimize import root + # Process keyword arguments + return_outputs = config._process_legacy_keyword( + kwargs, 'return_y', 'return_outputs', return_outputs) + if kwargs: + raise TypeError("unrecognized keyword(s): " + str(kwargs)) + # Process arguments for the root function root_kwargs = dict() if root_kwargs is None else root_kwargs if root_method: @@ -2019,14 +2025,14 @@ def rootfun(z): # Return the result based on what the user wants and what we found if return_result or result.success: return OperatingPoint( - z[0], z[1], z[2], result, return_y, return_result) + z[0], z[1], z[2], result, return_outputs, return_result) else: # Something went wrong, don't return anything return OperatingPoint( - None, None, None, result, return_y, return_result) + None, None, None, result, return_outputs, return_result) # TODO: remove code when ready - if not return_y: + if not return_outputs: z = z[0:2] # Strip y from result if not desired if return_result: # Return whatever we got, along with the result dictionary @@ -2036,7 +2042,7 @@ def rootfun(z): return z else: # Something went wrong, don't return anything - return (None, None, None) if return_y else (None, None) + return (None, None, None) if return_outputs else (None, None) # Linearize an input/output system diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 70a0cc3c1..2b1368aeb 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -53,6 +53,7 @@ control.create_estimator_iosystem: ['state_labels'], # deprecated control.bode_plot: ['sharex', 'sharey', 'margin_info'], # deprecated control.eigensys_realization: ['arg'], # quasi-positional + control.find_operating_point: ['method'], # internal use } # Decide on the level of verbosity (use -rP when running pytest) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 09f5596ec..d2570be01 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -10,10 +10,11 @@ import re import warnings -import pytest +from math import sqrt import numpy as np -from math import sqrt +import pytest +import scipy import control as ct @@ -2088,6 +2089,7 @@ def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect): # Test out new operating point version of find_eqpt +# TODO: add return_)y tests def test_find_operating_point(): dt = 1 sys = ct.NonlinearIOSystem( @@ -2122,6 +2124,10 @@ def test_find_operating_point(): assert op_point.inputs is not None assert op_point.result.success is False + # Check to make sure unknown keywords are caught + with pytest.raises(TypeError, match="unrecognized keyword"): + ct.find_operating_point(sys, x0, u0, unknown=None) + def test_operating_point(): dt = 1 @@ -2146,6 +2152,36 @@ def test_operating_point(): op_point = ct.find_operating_point( sys, 0, 0, root_method='lm', root_kwargs={'tol': 1e-6}) + # Make sure we can get back the right arguments in a tuple + op_point = ct.find_operating_point(sys, 0, 0, return_outputs=True) + assert len(op_point) == 3 + assert isinstance(op_point[0], np.ndarray) + assert isinstance(op_point[1], np.ndarray) + assert isinstance(op_point[2], np.ndarray) + + with pytest.warns(FutureWarning, match="return_outputs"): + op_point = ct.find_operating_point(sys, 0, 0, return_y=True) + assert len(op_point) == 3 + assert isinstance(op_point[0], np.ndarray) + assert isinstance(op_point[1], np.ndarray) + assert isinstance(op_point[2], np.ndarray) + + # Make sure we can get back the right arguments in a tuple + op_point = ct.find_operating_point(sys, 0, 0, return_result=True) + assert len(op_point) == 3 + assert isinstance(op_point[0], np.ndarray) + assert isinstance(op_point[1], np.ndarray) + assert isinstance(op_point[2], scipy.optimize.OptimizeResult) + + # Make sure we can get back the right arguments in a tuple + op_point = ct.find_operating_point( + sys, 0, 0, return_result=True, return_outputs=True) + assert len(op_point) == 4 + assert isinstance(op_point[0], np.ndarray) + assert isinstance(op_point[1], np.ndarray) + assert isinstance(op_point[2], np.ndarray) + assert isinstance(op_point[3], scipy.optimize.OptimizeResult) + def test_iosys_sample(): csys = ct.rss(2, 1, 1) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 020910e73..b98509d65 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -24,6 +24,7 @@ import control.tests.frd_test as frd_test import control.tests.freqplot_test as freqplot_test import control.tests.interconnect_test as interconnect_test +import control.tests.iosys_test as iosys_test import control.tests.optimal_test as optimal_test import control.tests.statefbk_test as statefbk_test import control.tests.stochsys_test as stochsys_test @@ -252,6 +253,8 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'dlqr': test_unrecognized_kwargs, 'drss': test_unrecognized_kwargs, 'feedback': test_unrecognized_kwargs, + 'find_eqpt': iosys_test.test_find_operating_point, + 'find_operating_point': iosys_test.test_find_operating_point, 'flatsys.flatsys': test_unrecognized_kwargs, 'frd': frd_test.TestFRD.test_unrecognized_keyword, 'gangof4': test_matplotlib_kwargs, From 47e1c0955ca5b400571ac408ff0c5eeb27371b49 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 10 Nov 2024 19:00:00 -0800 Subject: [PATCH 7/8] fix small typos in documentation (unrelated to main PR) --- doc/conventions.rst | 6 +++--- doc/intro.rst | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/conventions.rst b/doc/conventions.rst index 21f3ab82b..fb1f0715f 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -289,10 +289,10 @@ element of the `control.config.defaults` dictionary:: ct.config.defaults['module.parameter'] = value -The `~control.config.set_defaults` function can also be used to set multiple -configuration parameters at the same time:: +The :func:`~control.set_defaults` function can also be used to +set multiple configuration parameters at the same time:: - ct.config.set_defaults('module', param1=val1, param2=val2, ...] + ct.set_defaults('module', param1=val1, param2=val2, ...] Finally, there are also functions available set collections of variables based on standard configurations. diff --git a/doc/intro.rst b/doc/intro.rst index 2287bbac4..a45bb030e 100644 --- a/doc/intro.rst +++ b/doc/intro.rst @@ -56,7 +56,7 @@ they are not already present. .. note:: Mixing packages from conda-forge and the default conda channel can sometimes cause problems with dependencies, so it is usually best to - instally NumPy, SciPy, and Matplotlib from conda-forge as well. + install NumPy, SciPy, and Matplotlib from conda-forge as well. To install using pip:: From e6fdc17258dee3c3c7df0f48ed9d3b6718c42fbb Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 10 Nov 2024 19:16:22 -0800 Subject: [PATCH 8/8] updated per @slivingston review comments --- control/nlsys.py | 12 ++++++------ control/tests/iosys_test.py | 3 +-- examples/cruise-control.py | 2 +- examples/cruise.ipynb | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index d463ea7fc..835c16ef6 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -93,7 +93,7 @@ class NonlinearIOSystem(InputOutputSystem): generic name is generated with a unique integer id. params : dict, optional - Parameter values for the system. Passed to the evaluation functions + Parameter values for the system. Passed to the evaluation functions for the system as default values, overriding internal defaults. See Also @@ -1671,7 +1671,7 @@ def ivp_rhs(t, x): success=soln.success, message=message) -class OperatingPoint(object): +class OperatingPoint(): """A class for representing the operating point of a nonlinear I/O system. The ``OperatingPoint`` class stores the operating point of a nonlinear @@ -1693,18 +1693,18 @@ class OperatingPoint(object): """ def __init__( - self, states, inputs=None, outputs=None, result=None, + self, states, inputs, outputs=None, result=None, return_outputs=False, return_result=False): self.states = states self.inputs = inputs if outputs is None and return_outputs and not return_result: - raise SystemError("return_outputs specified by no y0 value") + raise ValueError("return_outputs specified but no outputs value") self.outputs = outputs self.return_outputs = return_outputs if result is None and return_result: - raise SystemError("return_result specified by no result value") + raise ValueError("return_result specified but no result value") self.result = result self.return_result = return_result @@ -1799,7 +1799,7 @@ def find_operating_point( values will be ignored in solving for an operating point. State indices can be listed in any order. By default, all updates will be fixed at `dx0` in searching for an operating point. - root_method : str, optonal + root_method : str, optional Method to find the operating point. If specified, this parameter is passed to the :func:`scipy.optimize.root` function. root_kwargs : dict, optional diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index d2570be01..baaee03f6 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -2089,7 +2089,6 @@ def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect): # Test out new operating point version of find_eqpt -# TODO: add return_)y tests def test_find_operating_point(): dt = 1 sys = ct.NonlinearIOSystem( @@ -2148,7 +2147,7 @@ def test_operating_point(): np.testing.assert_allclose(linsys_orig.C, linsys_oppt.C) np.testing.assert_allclose(linsys_orig.D, linsys_oppt.D) - # Call find_operating point with method and keyword arguments + # Call find_operating_point with method and keyword arguments op_point = ct.find_operating_point( sys, 0, 0, root_method='lm', root_kwargs={'tol': 1e-6}) diff --git a/examples/cruise-control.py b/examples/cruise-control.py index b8a91eeab..074be8fa8 100644 --- a/examples/cruise-control.py +++ b/examples/cruise-control.py @@ -349,7 +349,7 @@ def cruise_plot(sys, t, y, label=None, t_hill=None, vref=20, antiwindup=False, # and u given the gear, slope, and desired output velocity) X0, U0, Y0 = ct.find_operating_point( cruise_pi, [vref[0], 0], [vref[0], gear[0], theta0[0]], - y0=[0, vref[0]], iu=[1, 2], iy=[1], return_y=True) + y0=[0, vref[0]], iu=[1, 2], iy=[1], return_outputs=True) # Now simulate the effect of a hill at t = 5 seconds plt.figure() diff --git a/examples/cruise.ipynb b/examples/cruise.ipynb index 4f1c152f9..64eafbaf7 100644 --- a/examples/cruise.ipynb +++ b/examples/cruise.ipynb @@ -804,7 +804,7 @@ "# Compute the equilibrium throttle setting for the desired speed\n", "X0, U0, Y0 = ct.find_eqpt(\n", " cruise_pi, [vref[0], 0], [vref[0], gear[0], theta0[0]],\n", - " y0=[0, vref[0]], iu=[1, 2], iy=[1], return_y=True)\n", + " y0=[0, vref[0]], iu=[1, 2], iy=[1], return_outputs=True)\n", "\n", "# Now simulate the effect of a hill at t = 5 seconds\n", "plt.figure()\n", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy