Skip to content

Commit cf45109

Browse files
committed
initial implementation of optimal estimation problem
1 parent 823dfbc commit cf45109

7 files changed

Lines changed: 1177 additions & 31 deletions

File tree

control/optimal.py

Lines changed: 820 additions & 7 deletions
Large diffs are not rendered by default.

control/stochsys.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,8 @@ def create_estimator_iosystem(
354354
Disturbance matrix describing how the disturbances enters the
355355
dynamics. Defaults to sys.B.
356356
C : ndarray, optional
357-
If the system has all full states output, define the measured values
358-
to be used by the estimator. Otherwise, use the system output as the
357+
If the system has full state output, define the measured values to
358+
be used by the estimator. Otherwise, use the system output as the
359359
measured values.
360360
{state, covariance, sensor, output}_labels : str or list of str, optional
361361
Set the name of the signals to use for the internal state, covariance,

control/tests/kwargs_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup):
196196
flatsys_test.TestFlatSys.test_solve_flat_ocp_errors,
197197
'optimal.create_mpc_iosystem': optimal_test.test_mpc_iosystem_rename,
198198
'optimal.solve_ocp': optimal_test.test_ocp_argument_errors,
199+
'optimal.solve_oep': optimal_test.test_oep_argument_errors,
199200
'FrequencyResponseData.__init__':
200201
frd_test.TestFRD.test_unrecognized_keyword,
201202
'InputOutputSystem.__init__': test_unrecognized_kwargs,
@@ -217,7 +218,9 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup):
217218
'optimal.OptimalControlProblem.compute_trajectory':
218219
optimal_test.test_ocp_argument_errors,
219220
'optimal.OptimalControlProblem.create_mpc_iosystem':
220-
optimal_test.test_mpc_iosystem_rename,
221+
optimal_test.test_ocp_argument_errors,
222+
'optimal.OptimalEstimationProblem.__init__':
223+
optimal_test.test_oep_argument_errors,
221224
}
222225

223226
#
@@ -234,9 +237,6 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup):
234237
control.freqplot._add_arrows_to_line2D, # RMM, 18 Nov 2022
235238
control.namedio._process_dt_keyword, # RMM, 13 Nov 2022
236239
control.namedio._process_namedio_keywords, # RMM, 18 Nov 2022
237-
control.optimal.OptimalControlProblem.__init__, # RMM, 18 Nov 2022
238-
control.optimal.solve_ocp, # RMM, 18 Nov 2022
239-
control.optimal.create_mpc_iosystem, # RMM, 18 Nov 2022
240240
}
241241

242242
@pytest.mark.parametrize("module", [control, control.flatsys])

control/tests/optimal_test.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ def test_ocp_argument_errors():
529529
with pytest.raises(TypeError, match="unrecognized keyword"):
530530
ocp = opt.OptimalControlProblem(sys, time, cost, constraints)
531531
ocp.compute_trajectory(x0, unknown=None)
532-
532+
533533
# Unrecognized trajectory constraint type
534534
constraints = [(None, np.eye(3), [0, 0, 0], [0, 0, 0])]
535535
with pytest.raises(TypeError, match="unknown trajectory constraint type"):
@@ -771,3 +771,18 @@ def vehicle_output(t, x, u, params):
771771
np.testing.assert_almost_equal(y[:,-1], xf, decimal=1)
772772
else:
773773
np.testing.assert_almost_equal(y[:,-1], xf, decimal=1)
774+
775+
776+
def test_oep_argument_errors():
777+
sys = ct.rss(4, 2, 2)
778+
timepts = np.linspace(0, 1, 10)
779+
Y = np.zeros((2, timepts.size))
780+
U = np.zeros_like(timepts)
781+
cost = opt.gaussian_likelihood_cost(sys, np.eye(1), np.eye(2))
782+
783+
# Unrecognized arguments
784+
with pytest.raises(TypeError, match="unrecognized keyword"):
785+
res = opt.solve_oep(sys, timepts, Y, U, cost, unknown=True)
786+
787+
with pytest.raises(TypeError, match="unrecognized keyword"):
788+
oep = opt.OptimalEstimationProblem(sys, timepts, cost, unknown=True)

control/tests/stochsys_test.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from control.tests.conftest import asmatarrayout
77

88
import control as ct
9+
import control.optimal as opt
910
from control import lqe, dlqe, rss, drss, tf, ss, ControlArgument, slycot_check
1011
from math import log, pi
1112

@@ -317,3 +318,180 @@ def test_correlation():
317318
with pytest.raises(ValueError, match="Time values must be equally"):
318319
T = np.logspace(0, 2, T.size)
319320
tau, Rtau = ct.correlation(T, V)
321+
322+
@pytest.mark.parametrize('dt', [0, 1])
323+
def test_oep(dt):
324+
# Define the system to test, with additional input
325+
csys = ct.ss(
326+
[[-0.5, 1, 0, 0], [0, -1, 1, 0], [0, 0, -2, 1], [0, 0, 0, -3]], # A
327+
[[0, 0.1], [0, 0.1], [0, 0.1], [1, 0.1]], # B
328+
[[1, 0, 0, 0], [0, 0, 1, 0]], # C
329+
0, dt=0)
330+
dsys = ct.c2d(csys, dt)
331+
sys = csys if dt == 0 else dsys
332+
333+
# Create disturbances and noise (fixed, to avoid random errors)
334+
Rv = 0.1 * np.eye(1) # scalar disturbance
335+
Rw = 0.01 * np.eye(sys.noutputs)
336+
timepts = np.arange(0, 10.1, 1)
337+
V = np.array(
338+
[0 if t % 2 == 1 else 1 if t % 4 == 0 else -1 for t in timepts]
339+
).reshape(1, -1) * 0.1
340+
W = np.vstack([np.sin(2*timepts), np.cos(3*timepts)]) * 1e-3
341+
342+
# Generate system data
343+
U = np.sin(timepts)
344+
345+
# No disturbances
346+
res0 = ct.input_output_response(sys, timepts, [U, V*0])
347+
Y0 = res0.outputs
348+
349+
# With disturbances and noise
350+
res1 = ct.input_output_response(sys, timepts, [U, V])
351+
Y1 = res1.outputs + W
352+
353+
#
354+
# Internal testing to make sure all our functions are OK
355+
#
356+
357+
# Set up optimal estimation function using Gaussian likelihoods for cost
358+
traj_cost = opt.gaussian_likelihood_cost(sys, Rv, Rw)
359+
init_cost = lambda xhat, x: (xhat - x) @ (xhat - x)
360+
oep = opt.OptimalEstimationProblem(
361+
sys, timepts, traj_cost, terminal_cost=init_cost)
362+
363+
# _cost_function
364+
oep.compute_estimate(res0.outputs, U, X0=0)
365+
assert oep._cost_function(np.hstack(
366+
[res0.states.reshape(-1), V.reshape(-1) * 0])) == 0
367+
assert oep._cost_function(np.hstack(
368+
[res0.states.reshape(-1), V.reshape(-1)])) != 0
369+
if sys.isdtime():
370+
# Collocation contstraint should be satisified for discrete time
371+
np.testing.assert_allclose(oep._collocation_constraint(
372+
np.hstack([res0.states.reshape(-1), V.reshape(-1) * 0])), 0)
373+
374+
# _compute_states_inputs: states and inputs with no noise
375+
oep.compute_estimate(Y0, U)
376+
xhat, u, v, w = oep._compute_states_inputs(
377+
np.hstack([res0.states.reshape(-1), V.reshape(-1) * 0]))
378+
np.testing.assert_allclose(xhat, res0.states)
379+
np.testing.assert_allclose(u, U.reshape(1, -1))
380+
np.testing.assert_allclose(v, 0)
381+
np.testing.assert_allclose(w, 0)
382+
383+
# _compute_states_inputs: states and inputs with no noise
384+
oep.compute_estimate(Y1, U)
385+
xhat, u, v, w = oep._compute_states_inputs(
386+
np.hstack([res1.states.reshape(-1), V.reshape(-1)]))
387+
np.testing.assert_allclose(xhat, res1.states)
388+
np.testing.assert_allclose(u, U.reshape(1, -1))
389+
np.testing.assert_allclose(v, V)
390+
np.testing.assert_allclose(w, W)
391+
392+
#
393+
# oep.compute_estimate testing
394+
#
395+
396+
# Noise free and disturbance free
397+
nonoise_cost = opt.gaussian_likelihood_cost(sys, Rv, Rw/1e6)
398+
oep0 = opt.OptimalEstimationProblem(
399+
sys, timepts, nonoise_cost, terminal_cost=init_cost)
400+
est0 = oep0.compute_estimate(Y0, U)
401+
if sys.isdtime():
402+
# Full state estimate should be near perfect
403+
np.testing.assert_allclose(
404+
est0.states[:, -1], res0.states[:, -1], atol=1e-3, rtol=1e-3)
405+
np.testing.assert_allclose(est0.inputs, 0, atol=1e-2, rtol=1e-3)
406+
np.testing.assert_allclose(est0.outputs, 0, atol=1e-2, rtol=1e-3)
407+
else:
408+
# Estimate at end of trajectory should be very close
409+
assert est0.success
410+
np.testing.assert_allclose(
411+
est0.states[:, -1], res0.states[:, -1], atol=1e-2, rtol=1e-2)
412+
413+
# Noise free, but with disturbances and good initial guess
414+
oep1 = opt.OptimalEstimationProblem(
415+
sys, timepts, nonoise_cost, terminal_cost=init_cost)
416+
est1 = oep1.compute_estimate(
417+
res1.outputs, U, initial_guess=(res1.states, V), X0=0)
418+
np.testing.assert_allclose(
419+
est1.states[:, -1], res1.states[:, -1], atol=1e-2, rtol=1e-2)
420+
if sys.isdtime():
421+
# For discrete time, estimated disturbance and noise should be close
422+
np.testing.assert_allclose(
423+
est1.inputs[:-1], V[:-1], atol=1e-2, rtol=1e-2)
424+
np.testing.assert_allclose(est1.outputs, 0, atol=1e-2, rtol=1e-2)
425+
426+
# Noise and disturbances (the standard case)
427+
est2 = oep.compute_estimate(Y1, U) # back to original OEP
428+
assert est2.success
429+
np.testing.assert_allclose(
430+
est2.states[:, -1], res1.states[:, -1], atol=1e-1, rtol=1e-2)
431+
432+
#
433+
# Disturbance constraints
434+
#
435+
436+
V3 = np.clip(V, 0.5, 1)
437+
traj_constraint = opt.disturbance_range_constraint(sys, 0.5, 1)
438+
oep3 = opt.OptimalEstimationProblem(
439+
sys, timepts, traj_cost, terminal_cost=init_cost,
440+
trajectory_constraints=traj_constraint)
441+
442+
res3 = ct.input_output_response(sys, timepts, [U, V3])
443+
Y3 = res3.outputs + W
444+
445+
# Make sure the constraint screws up the estimation problem
446+
with pytest.raises(AssertionError):
447+
est3 = oep.compute_estimate(Y3, U)
448+
np.testing.assert_allclose(
449+
est3.states[:, -1], res3.states[:, -1], atol=1e-1, rtol=1e-2)
450+
451+
# Make sure estimation is correct with constraint in place
452+
est3 = oep3.compute_estimate(Y3, U)
453+
assert est3.success
454+
np.testing.assert_allclose(
455+
est3.states[:, -1], res3.states[:, -1], atol=1e-1, rtol=1e-2)
456+
457+
458+
def test_mhe():
459+
# Define the system to test, with additional input
460+
csys = ct.ss(
461+
[[-0.5, 1, 0, 0], [0, -1, 1, 0], [0, 0, -2, 1], [0, 0, 0, -3]], # A
462+
[[0, 0.1], [0, 0.1], [0, 0.1], [1, 0.1]], # B
463+
[[1, 0, 0, 0], [0, 0, 1, 0]], # C
464+
0, dt=0)
465+
dt = 0.1
466+
sys = ct.c2d(csys, dt)
467+
468+
# Create disturbances and noise (fixed, to avoid random errors)
469+
Rv = 0.1 * np.eye(1) # scalar disturbance
470+
Rw = 1e-6 * np.eye(sys.noutputs)
471+
P0 = 0.1 * np.eye(sys.nstates)
472+
473+
timepts = np.arange(0, 10*dt, dt)
474+
mhe_timepts = np.arange(0, 5*dt, dt)
475+
V = np.array(
476+
[0 if i % 2 == 1 else 1 if i % 4 == 0 else -1
477+
for i, t in enumerate(timepts)]).reshape(1, -1) * 0.1
478+
W = np.sin(timepts / dt) * 1e-3
479+
480+
# Create a moving horizon estimator
481+
traj_cost = opt.gaussian_likelihood_cost(sys, Rv, Rw)
482+
init_cost = lambda xhat, x: (xhat - x) @ P0 @ (xhat - x)
483+
oep = opt.OptimalEstimationProblem(
484+
sys, mhe_timepts, traj_cost, terminal_cost=init_cost)
485+
mhe = oep.create_mhe_iosystem(1)
486+
487+
# Generate system data
488+
U = 10 * np.sin(timepts / (4*dt))
489+
inputs = np.vstack([U, V])
490+
resp = ct.input_output_response(sys, timepts, inputs)
491+
492+
# Run the estimator
493+
estp = ct.input_output_response(
494+
mhe, timepts, [resp.outputs, resp.inputs[0:1]])
495+
496+
# Make sure the estimated state is close to the actual state
497+
np.testing.assert_allclose(estp.outputs, resp.states, atol=1e-2, rtol=1e-4)

doc/conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@
127127
# Add any paths that contain custom static files (such as style sheets) here,
128128
# relative to this directory. They are copied after the builtin static files,
129129
# so a file named "default.css" will overwrite the builtin "default.css".
130-
# html_static_path = ['_static']
130+
131+
html_static_path = ['_static']
132+
def setup(app):
133+
app.add_css_file('css/custom.css')
131134

132135
# Custom sidebar templates, must be a dictionary that maps document names
133136
# to template names.

0 commit comments

Comments
 (0)