├── src └── nfoursid │ ├── __init__.py │ ├── tests │ ├── __init__.py │ ├── test_nfoursid.py │ ├── test_state_space.py │ ├── test_utils.py │ ├── test_end_to_end.py │ └── test_kalman.py │ ├── utils.py │ ├── state_space.py │ ├── nfoursid.py │ └── kalman.py ├── examples └── .gitignore ├── docs ├── .gitignore ├── source │ ├── utils.rst │ ├── kalman.rst │ ├── nfoursid.rst │ ├── state_space.rst │ └── modules.rst ├── Makefile ├── make.bat ├── conf.py └── index.rst ├── setup.py ├── .gitignore ├── pyproject.toml ├── dev-requirements.txt ├── .readthedocs.yaml ├── .github └── workflows │ ├── python-tests.yml │ ├── upload-pypi.yml │ └── python-tests-darts.yml ├── setup.cfg ├── LICENSE └── README.md /src/nfoursid/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nfoursid/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | _static 3 | _templates 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | htmlcov 4 | .coverage 5 | dist 6 | build 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib>=3.3 2 | numpy>=1.19 3 | pandas>=1.1 4 | pytest>=6.2 5 | coverage>=5.5 6 | sphinx>=4.0 7 | notebook>=6.3 8 | -------------------------------------------------------------------------------- /docs/source/utils.rst: -------------------------------------------------------------------------------- 1 | utils 2 | ===== 3 | 4 | .. automodule:: nfoursid.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/kalman.rst: -------------------------------------------------------------------------------- 1 | kalman 2 | ====== 3 | 4 | .. automodule:: nfoursid.kalman 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/nfoursid.rst: -------------------------------------------------------------------------------- 1 | nfoursid 2 | ======== 3 | 4 | .. automodule:: nfoursid.nfoursid 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/state_space.rst: -------------------------------------------------------------------------------- 1 | state\_space 2 | ============ 3 | 4 | .. automodule:: nfoursid.state_space 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | nfoursid 2 | ===================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | kalman 8 | nfoursid 9 | state_space 10 | utils 11 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | python: 13 | version: 3.7 14 | install: 15 | - method: pip 16 | path: . -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.13' 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | python -m pip install -e . 22 | python -m pip install -r dev-requirements.txt 23 | 24 | - name: Run tests 25 | run: | 26 | python -m pytest --pyargs nfoursid.tests 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = nfoursid 3 | version = 1.0.2 4 | author = Steven van Gemert 5 | author_email = steven@vangemert.dev 6 | description = Implementation of N4SID, Kalman filtering and state-space models 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/spmvg/nfoursid 10 | license = MIT 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | License :: OSI Approved :: MIT License 14 | Programming Language :: Python :: 3 15 | 16 | [options] 17 | package_dir = 18 | = src 19 | packages = find: 20 | python_requires = >=3.7 21 | install_requires = 22 | matplotlib >=3.3 23 | numpy >=1.19 24 | pandas >=1.1 25 | 26 | [options.packages.find] 27 | where = src -------------------------------------------------------------------------------- /.github/workflows/upload-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Upload to PyPi 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/nfoursid 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.10' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install --upgrade build 27 | - name: Build package 28 | run: python -m build 29 | - name: Publish package 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | -------------------------------------------------------------------------------- /.github/workflows/python-tests-darts.yml: -------------------------------------------------------------------------------- 1 | name: Run tests for Darts components 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.13' 17 | 18 | - name: Install Darts dependencies and local nfoursid 19 | run: | 20 | python -m pip install u8darts 21 | python -m pip install pre-commit pytest-cov testfixtures 22 | python -m pip install -e . 23 | python -m pip list 24 | 25 | - name: Make sure specific parts of Darts don't regress 26 | run: | 27 | python -m pytest --pyargs darts.tests.models.filtering darts.tests.models.forecasting.test_local_forecasting_models darts.tests.models.forecasting.test_probabilistic_models 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Steven van Gemert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/nfoursid/tests/test_nfoursid.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from nfoursid.nfoursid import NFourSID 6 | from nfoursid.state_space import StateSpace 7 | 8 | 9 | class TestNFourSid(unittest.TestCase): 10 | def test_which_is_actually_regression_test(self): 11 | n_datapoints = 100 12 | model = StateSpace( 13 | np.array([[.5]]), 14 | np.array([[.6]]), 15 | np.array([[.7]]), 16 | np.array([[.8]]), 17 | ) 18 | np.random.seed(0) 19 | for _ in range(n_datapoints): 20 | model.step(np.random.standard_normal((1, 1))) 21 | 22 | nfoursid = NFourSID( 23 | model.to_dataframe(), 24 | model.y_column_names, 25 | input_columns=model.u_column_names, 26 | num_block_rows=2 27 | ) 28 | nfoursid.subspace_identification() 29 | identified_model, covariance_matrix = nfoursid.system_identification(rank=1) 30 | 31 | # matrices `a` and `d` don't have freedom of choice: they should be fitted well 32 | self.assertTrue(is_slightly_close(.5, identified_model.a)) 33 | self.assertTrue(is_slightly_close(.8, identified_model.d)) 34 | self.assertTrue(np.all(is_slightly_close(0, covariance_matrix))) 35 | 36 | 37 | def is_slightly_close(matrix, number): 38 | return np.isclose(matrix, number, rtol=0, atol=1e-3) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NFourSID 2 | 3 | Implementation of the N4SID algorithm for subspace identification [1], together with Kalman filtering and state-space 4 | models. 5 | 6 | State-space models are versatile models for representing multi-dimensional timeseries. 7 | As an example, the ARMAX(_p_, _q_, _r_)-models - AutoRegressive MovingAverage with eXogenous input - 8 | are included in the representation of state-space models. 9 | By extension, ARMA-, AR- and MA-models can be described, too. 10 | The numerical implementations are based on [2]. 11 | 12 | ## Installation 13 | Releases are made available on PyPi. 14 | The recommended installation method is via `pip`: 15 | 16 | ```python 17 | pip install nfoursid 18 | ``` 19 | 20 | For a development setup, the requirements are in `dev-requirements.txt`. 21 | Subsequently, this repo can be locally `pip`-installed. 22 | 23 | ## Documentation and code example 24 | Documentation is provided [here](https://nfoursid.readthedocs.io/en/latest/). 25 | An example Jupyter notebook is provided [here](https://github.com/spmvg/nfoursid/blob/master/examples/Overview.ipynb). 26 | 27 | ## References 28 | 29 | 1. Van Overschee, Peter, and Bart De Moor. "N4SID: Subspace algorithms for the identification of combined 30 | deterministic-stochastic systems." Automatica 30.1 (1994): 75-93. 31 | 2. Verhaegen, Michel, and Vincent Verdult. _Filtering and system identification: a least squares approach._ 32 | Cambridge university press, 2007. 33 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'NFourSID' 21 | copyright = '2021, Steven van Gemert' 22 | author = 'Steven van Gemert' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.imgmath' 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'alabaster' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. NFourSID documentation master file, created by 2 | sphinx-quickstart on Thu May 13 16:12:02 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | NFourSID 7 | ======== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | source/modules.rst 13 | 14 | Overview 15 | -------- 16 | 17 | Implementation of the N4SID algorithm for subspace identification [1], together with Kalman filtering and state-space 18 | models. 19 | 20 | State-space models are versatile models for representing multi-dimensional timeseries. 21 | As an example, the ARMAX(*p*, *q*, *r*)-models - AutoRegressive MovingAverage with eXogenous input - are included in the representation of state-space models. 22 | By extension, ARMA-, AR- and MA-models can be described, too. 23 | The numerical implementations are based on [2]. 24 | 25 | The state-space model of interest has the following form: 26 | 27 | .. math:: 28 | \begin{cases} 29 | x_{k+1} &= A x_k + B u_k + K e_k \\ 30 | y_k &= C x_k + D u_k + e_k 31 | \end{cases} 32 | 33 | where 34 | 35 | - :math:`k \in \mathbb{N}` is the timestep, 36 | - :math:`y_k \in \mathbb{R}^{d_y}` is the output vector with dimension :math:`d_y`, 37 | - :math:`u_k \in \mathbb{R}^{d_u}` is the input vector with dimension :math:`d_u`, 38 | - :math:`x_k \in \mathbb{R}^{d_x}` is the internal state vector with dimension :math:`d_x`, 39 | - :math:`e_k \in \mathbb{R}^{d_y}` is the noise vector with dimension :math:`d_y`, 40 | - :math:`(A, B, C, D)` are system matrices describing time dynamics and input-output coupling, 41 | - :math:`K` is a system matrix describing noise relationships. 42 | 43 | Code example 44 | ------------ 45 | An example Jupyter notebook is provided `here `_. 46 | 47 | References 48 | ---------- 49 | 50 | 1. Van Overschee, Peter, and Bart De Moor. "N4SID: Subspace algorithms for the identification of combined 51 | deterministic-stochastic systems." Automatica 30.1 (1994): 75-93. 52 | 2. Verhaegen, Michel, and Vincent Verdult. *Filtering and system identification: a least squares approach.* 53 | Cambridge university press, 2007. 54 | 55 | 56 | Indices and tables 57 | ================== 58 | 59 | * :ref:`genindex` 60 | * :ref:`modindex` 61 | * :ref:`search` 62 | -------------------------------------------------------------------------------- /src/nfoursid/tests/test_state_space.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from nfoursid.state_space import StateSpace 6 | 7 | 8 | class TestStateSpace(unittest.TestCase): 9 | def setUp(self) -> None: 10 | self.a = np.array([[2]]) 11 | self.b = np.array([[3]]) 12 | self.c = np.array([[5]]) 13 | self.d = np.array([[7]]) 14 | self.k = np.array([[11]]) 15 | 16 | def test_output(self): 17 | state = np.array([[1]]) 18 | u = np.array([[2]]) 19 | e = np.array([[3]]) 20 | 21 | model = StateSpace( 22 | self.a, 23 | self.b, 24 | self.c, 25 | self.d, 26 | self.k 27 | ) 28 | 29 | result = model.output(state) 30 | self.assertAlmostEqual(5, result[0, 0]) 31 | 32 | result = model.output(state, u=u) 33 | self.assertAlmostEqual(19, result[0, 0]) 34 | 35 | result = model.output(state, e=e) 36 | self.assertAlmostEqual(8, result[0, 0]) 37 | 38 | def test_step(self): 39 | u = np.array([[2]]) 40 | e = np.array([[3]]) 41 | 42 | model = StateSpace( 43 | self.a, 44 | self.b, 45 | self.c, 46 | self.d, 47 | self.k 48 | ) 49 | 50 | y = model.step() 51 | self.assertAlmostEqual(0, y[0, 0]) 52 | y = model.step(u) 53 | self.assertAlmostEqual(14, y[0, 0]) 54 | y = model.step(u=u) 55 | self.assertAlmostEqual(44, y[0, 0]) 56 | y = model.step(e=e) 57 | self.assertAlmostEqual(93, y[0, 0]) 58 | 59 | result = model.to_dataframe().to_numpy() 60 | self.assertTrue(np.all(np.isclose( 61 | np.array([ 62 | [0, 0], 63 | [2, 14], 64 | [2, 44], 65 | [0, 93] 66 | ]), 67 | result 68 | ))) 69 | 70 | def test_autonomous_system(self): 71 | model = StateSpace( 72 | self.a, 73 | np.zeros((1, 0)), 74 | self.c, 75 | np.zeros((1, 0)), 76 | k=np.array([[1]]) 77 | ) 78 | e = np.array([[1]]) 79 | 80 | with self.assertRaises(ValueError): 81 | model.step(np.array([[1]])) 82 | y = model.step() 83 | self.assertEqual((1, 1), y.shape) 84 | self.assertAlmostEqual(0, y[0, 0]) 85 | y = model.step(e=e) 86 | self.assertAlmostEqual(1, y[0, 0]) 87 | y = model.step(e=e) 88 | self.assertAlmostEqual(6, y[0, 0]) 89 | y = model.step(e=e) 90 | self.assertAlmostEqual(16, y[0, 0]) 91 | -------------------------------------------------------------------------------- /src/nfoursid/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from nfoursid.utils import Utils 6 | 7 | 8 | class TestUtils(unittest.TestCase): 9 | def test_block_hankel_matrix(self): 10 | matrix = np.array(range(15)).reshape((5, 3)) 11 | hankel = Utils.block_hankel_matrix(matrix, 2) 12 | desired_result = np.array([ 13 | [0., 3., 6., 9.], 14 | [1., 4., 7., 10.], 15 | [2., 5., 8., 11.], 16 | [3., 6., 9., 12.], 17 | [4., 7., 10., 13.], 18 | [5., 8., 11., 14.], 19 | ]) 20 | self.assertTrue(np.all(np.isclose(desired_result, hankel))) 21 | 22 | def test_eigenvalue_decomposition(self): 23 | matrix = np.fliplr(np.diag(range(1, 3))) 24 | decomposition = Utils.eigenvalue_decomposition(matrix) 25 | self.assertTrue(np.all(np.isclose( 26 | [[0, -1], 27 | [-1, 0]], 28 | decomposition.left_orthogonal 29 | ))) 30 | self.assertTrue(np.all(np.isclose( 31 | [2, 1], 32 | np.diagonal(decomposition.eigenvalues) 33 | ))) 34 | self.assertTrue(np.all(np.isclose( 35 | [[-1, 0], 36 | [0, -1]], 37 | decomposition.right_orthogonal 38 | ))) 39 | 40 | reduced_decomposition = Utils.reduce_decomposition(decomposition, 1) 41 | self.assertTrue(np.all(np.isclose( 42 | [[0], [-1]], 43 | reduced_decomposition.left_orthogonal 44 | ))) 45 | self.assertTrue(np.all(np.isclose( 46 | [[2]], 47 | reduced_decomposition.eigenvalues 48 | ))) 49 | self.assertTrue(np.all(np.isclose( 50 | [[-1, 0]], 51 | reduced_decomposition.right_orthogonal 52 | ))) 53 | 54 | def test_vectorize(self): 55 | matrix = np.array([ 56 | [0, 2], 57 | [1, 3] 58 | ]) 59 | result = Utils.vectorize(matrix) 60 | self.assertTrue(np.all(np.isclose( 61 | np.array([ 62 | [0], 63 | [1], 64 | [2], 65 | [3], 66 | ]), 67 | result 68 | ))) 69 | 70 | def test_unvectorize(self): 71 | matrix = np.array([ 72 | [0], 73 | [1], 74 | [2], 75 | [3], 76 | ]) 77 | result = Utils.unvectorize(matrix, num_rows=2) 78 | self.assertTrue(np.all(np.isclose( 79 | np.array([ 80 | [0, 2], 81 | [1, 3] 82 | ]), 83 | result 84 | ))) 85 | 86 | with self.assertRaises(ValueError): 87 | Utils.unvectorize(matrix, num_rows=3) 88 | 89 | incompatible_matrix = matrix.T 90 | with self.assertRaises(ValueError): 91 | Utils.unvectorize(incompatible_matrix, 1) 92 | 93 | def test_validate_matrix_shape(self): 94 | with self.assertRaises(ValueError): 95 | Utils.validate_matrix_shape( 96 | np.array([[0]]), 97 | (42), 98 | 'error' 99 | ) 100 | -------------------------------------------------------------------------------- /src/nfoursid/tests/test_end_to_end.py: -------------------------------------------------------------------------------- 1 | # This is a test based on the example Jupyter notebook 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | import pandas as pd 5 | import unittest 6 | 7 | from nfoursid.kalman import Kalman 8 | from nfoursid.nfoursid import NFourSID 9 | from nfoursid.state_space import StateSpace 10 | 11 | pd.set_option('display.max_columns', None) 12 | np.random.seed(0) # reproducable results 13 | 14 | NUM_TRAINING_DATAPOINTS = 1000 # create a training-set by simulating a state-space model with this many datapoints 15 | NUM_TEST_DATAPOINTS = 20 # same for the test-set 16 | INPUT_DIM = 3 17 | OUTPUT_DIM = 2 18 | INTERNAL_STATE_DIM = 4 # actual order of the state-space model in the training- and test-set 19 | NOISE_AMPLITUDE = .1 # add noise to the training- and test-set 20 | FIGSIZE = 8 21 | ORDER_OF_MODEL_TO_FIT = 4 22 | 23 | # define system matrices for the state-space model of the training- and test-set 24 | A = np.array([ 25 | [1, .01, 0, 0], 26 | [0, 1, .01, 0], 27 | [0, 0, 1, .02], 28 | [0, -.01, 0, 1], 29 | ]) / 1.01 30 | B = np.array([ 31 | [1, 0, 0], 32 | [0, 1, 0], 33 | [0, 0, 1], 34 | [0, 1, 1], 35 | ] 36 | ) / 3 37 | C = np.array([ 38 | [1, 0, 1, 1], 39 | [0, 0, 1, -1], 40 | ]) 41 | D = np.array([ 42 | [1, 0, 1], 43 | [0, 1, 0] 44 | ]) / 10 45 | 46 | 47 | class TestEndToEnd(unittest.TestCase): 48 | def test_end_to_end(self): 49 | state_space = StateSpace(A, B, C, D) 50 | for _ in range(NUM_TRAINING_DATAPOINTS): 51 | input_state = np.random.standard_normal((INPUT_DIM, 1)) 52 | noise = np.random.standard_normal((OUTPUT_DIM, 1)) * NOISE_AMPLITUDE 53 | 54 | state_space.step(input_state, noise) 55 | 56 | nfoursid = NFourSID( 57 | state_space.to_dataframe(), # the state-space model can summarize inputs and outputs as a dataframe 58 | output_columns=state_space.y_column_names, 59 | input_columns=state_space.u_column_names, 60 | num_block_rows=10 61 | ) 62 | nfoursid.subspace_identification() 63 | 64 | state_space_identified, covariance_matrix = nfoursid.system_identification( 65 | rank=ORDER_OF_MODEL_TO_FIT 66 | ) 67 | 68 | kalman = Kalman(state_space_identified, covariance_matrix) 69 | state_space = StateSpace(A, B, C, D) # new data for the test-set 70 | for _ in range(NUM_TEST_DATAPOINTS): # make a test-set 71 | input_state = np.random.standard_normal((INPUT_DIM, 1)) 72 | noise = np.random.standard_normal((OUTPUT_DIM, 1)) * NOISE_AMPLITUDE 73 | 74 | y = state_space.step(input_state, noise) # generate test-set 75 | kalman.step(y, 76 | input_state) # the Kalman filter sees the output and input, but not the actual internal state 77 | 78 | dataframe = kalman.to_dataframe() 79 | last_row = dataframe.iloc[-1, :].to_dict() 80 | 81 | expected_result = { 82 | ('$y_0$', 'actual', 'output'): -0.7076730559552813, 83 | ('$y_0$', 'filtered', 'output'): -0.8661379329447575, 84 | ('$y_0$', 'filtered', 'standard deviation'): 0.12009260340845236, 85 | ('$y_0$', 'next predicted (input corrected)', 'output'): np.nan, 86 | ('$y_0$', 'next predicted (input corrected)', 'standard deviation'): 0.12049142178612798, 87 | ('$y_0$', 'next predicted (no input)', 'output'): -1.332263284464381, 88 | ('$y_0$', 'next predicted (no input)', 'standard deviation'): 0.12049142178612798, 89 | ('$y_1$', 'actual', 'output'): -0.84406136346813, 90 | ('$y_1$', 'filtered', 'output'): -0.7894290554207276, 91 | ('$y_1$', 'filtered', 'standard deviation'): 0.11920775718600453, 92 | ('$y_1$', 'next predicted (input corrected)', 'output'): np.nan, 93 | ('$y_1$', 'next predicted (input corrected)', 'standard deviation'): 0.11962978819874831, 94 | ('$y_1$', 'next predicted (no input)', 'output'): -0.08334764426935866, 95 | ('$y_1$', 'next predicted (no input)', 'standard deviation'): 0.11962978819874831 96 | } 97 | for key, value in expected_result.items(): 98 | if pd.notna(value): 99 | self.assertAlmostEqual( 100 | value, 101 | last_row[key], 102 | ) 103 | continue 104 | self.assertTrue( 105 | pd.isnull(last_row[key]), 106 | ) 107 | -------------------------------------------------------------------------------- /src/nfoursid/utils.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import Tuple 3 | 4 | import numpy as np 5 | 6 | Decomposition = namedtuple('Decomposition', ['left_orthogonal', 'eigenvalues', 'right_orthogonal']) 7 | """ 8 | Eigenvalue decomposition of a matrix ``matrix`` such that ``left_orthogonal @ eigenvalues @ right_orthogonal`` 9 | equals ``matrix``. 10 | """ 11 | 12 | 13 | class Utils: 14 | @staticmethod 15 | def validate_matrix_shape( 16 | matrix: np.ndarray, 17 | shape: Tuple[float, float], 18 | name: str 19 | ): 20 | """ 21 | Raises if ``matrix`` does not have shape ``shape``. The error message will contain ``name``. 22 | """ 23 | if matrix.shape != shape: 24 | raise ValueError(f'Dimensions of `{name}` {matrix.shape} are inconsistent. Expected {shape}.') 25 | 26 | @staticmethod 27 | def eigenvalue_decomposition( 28 | matrix: np.ndarray 29 | ) -> Decomposition: 30 | """ 31 | Calculate eigenvalue decomposition of ``matrix`` as a ``Decomposition``. 32 | """ 33 | u, eigenvalues, vh = np.linalg.svd(matrix) 34 | eigenvalues_mat = np.zeros((u.shape[0], vh.shape[0])) 35 | np.fill_diagonal(eigenvalues_mat, eigenvalues) 36 | return Decomposition(u, eigenvalues_mat, vh) 37 | 38 | @staticmethod 39 | def reduce_decomposition( 40 | decomposition: Decomposition, 41 | rank: int 42 | ) -> Decomposition: 43 | """ 44 | Reduce an eigenvalue decomposition ``decomposition`` such that only ``rank`` number of biggest eigenvalues 45 | remain. Returns another ``Decomposition``. 46 | """ 47 | u, s, vh = decomposition 48 | return Decomposition( 49 | u[:, :rank], 50 | s[:rank, :rank], 51 | vh[:rank, :] 52 | ) 53 | 54 | @staticmethod 55 | def block_hankel_matrix( 56 | matrix: np.ndarray, 57 | num_block_rows: int 58 | ) -> np.ndarray: 59 | """ 60 | Calculate a block Hankel matrix based on input matrix ``matrix`` with ``num_block_rows`` block rows. 61 | The shape of ``matrix`` is interpreted in row-order, like the structure of a ``pd.DataFrame``: 62 | the rows are measurements and the columns are data sources. 63 | 64 | The returned block Hankel matrix has a columnar structure. Every column of the returned matrix consists 65 | of ``num_block_rows`` block rows (measurements). See the examples for details. 66 | 67 | Examples 68 | -------- 69 | Suppose that the input matrix contains 4 measurements of 2-dimensional data: 70 | 71 | >>> matrix = np.array([ 72 | >>> [0, 1], 73 | >>> [2, 3], 74 | >>> [4, 5], 75 | >>> [6, 7] 76 | >>> ]) 77 | 78 | If the number of block rows is set to ``num_block_rows=2``, then the block Hankel matrix will be 79 | 80 | >>> np.array([ 81 | >>> [0, 2, 4], 82 | >>> [1, 3, 5], 83 | >>> [2, 4, 6], 84 | >>> [3, 5, 7] 85 | >>> ]) 86 | """ 87 | hankel_rows_dim = num_block_rows * matrix.shape[1] 88 | hankel_cols_dim = matrix.shape[0] - num_block_rows + 1 89 | 90 | hankel = np.zeros((hankel_rows_dim, hankel_cols_dim)) 91 | for block_row_index in range(hankel_cols_dim): 92 | flattened_block_rows = matrix[block_row_index:block_row_index+num_block_rows, 93 | :].flatten() 94 | hankel[:, block_row_index] = flattened_block_rows 95 | return hankel 96 | 97 | @staticmethod 98 | def vectorize( 99 | matrix: np.ndarray 100 | ) -> np.ndarray: 101 | """ 102 | Given a matrix ``matrix`` of shape ``(a, b)``, return a vector of shape ``(a*b, 1)`` with all columns of 103 | ``matrix`` stacked on top of eachother. 104 | """ 105 | return np.reshape(matrix.flatten(order='F'), (matrix.shape[0] * matrix.shape[1], 1)) 106 | 107 | @staticmethod 108 | def unvectorize( 109 | vector: np.ndarray, 110 | num_rows: int 111 | ) -> np.ndarray: 112 | """ 113 | Given a vector ``vector`` of shape ``(num_rows*b, 1)``, return a matrix of shape ``(num_rows, b)`` such that 114 | the stacked columns of the returned matrix equal ``vector``. 115 | """ 116 | if vector.shape[0] % num_rows != 0 or vector.shape[1] != 1: 117 | raise ValueError(f'Vector shape {vector.shape} and `num_rows`={num_rows} are incompatible') 118 | return vector.reshape((num_rows, vector.shape[0] // num_rows), order='F') 119 | -------------------------------------------------------------------------------- /src/nfoursid/tests/test_kalman.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import unittest 3 | 4 | import numpy as np 5 | 6 | from nfoursid.kalman import Kalman 7 | from nfoursid.state_space import StateSpace 8 | 9 | 10 | class TestKalman(unittest.TestCase): 11 | def setUp(self) -> None: 12 | self.x_init = np.array([[2]]) 13 | self.model = StateSpace( 14 | np.array([[.5]]), 15 | np.array([[.6]]), 16 | np.array([[.7]]), 17 | np.array([[.8]]), 18 | np.array([[1]]), 19 | x_init=self.x_init 20 | ) 21 | 22 | def test_step(self): 23 | n_datapoints = 100 24 | noise_reduction = 1e5 25 | 26 | kalman = Kalman(copy.deepcopy(self.model), np.eye(2) / (noise_reduction ** 2)) 27 | np.random.seed(0) 28 | for _ in range(n_datapoints): 29 | u = np.random.standard_normal((1, 1)) 30 | 31 | y = self.model.step(u, np.random.standard_normal((1, 1)) / noise_reduction) 32 | kalman.step(y, u) 33 | 34 | self.assertTrue(is_slightly_close( 35 | 2.0593793476904603, 36 | kalman.y_filtereds[-1] 37 | )) 38 | 39 | def test_step_with_nans(self): 40 | n_datapoints = 5 41 | 42 | kalman = Kalman(copy.deepcopy(self.model), np.eye(2)) 43 | np.random.seed(0) 44 | for i in range(n_datapoints): 45 | u = np.random.standard_normal((1, 1)) 46 | 47 | y = self.model.step(u, np.random.standard_normal((1, 1))) 48 | if i == 3: 49 | y = None # illustrate a missing output 50 | kalman.step(y, u) 51 | 52 | self.assertEqual( 53 | [np.isnan(x[0, 0]) for x in kalman.ys], 54 | [False, False, False, True, False] 55 | ) 56 | self.assertTrue(is_slightly_close( 57 | 1.3813007321174262, 58 | kalman.ys[-1] 59 | )) 60 | 61 | def test_extrapolate(self): 62 | n_to_predict = 4 63 | kalman = Kalman(copy.deepcopy(self.model), np.eye(2)) 64 | kalman.x_predicteds = [ 65 | self.x_init 66 | ] 67 | predictions = kalman.extrapolate(n_to_predict) 68 | 69 | self.assertTrue(np.all(np.isclose( 70 | np.array([ 71 | [1.4], 72 | [.7], 73 | [.35], 74 | [.175] 75 | ]), 76 | predictions.to_numpy() 77 | ))) 78 | 79 | def test_measurement_and_state_standard_deviation(self): 80 | n_steps = 3 81 | noise_variance = 9 82 | state_variance = 16 / self.model.c[0, 0] ** 2 83 | 84 | kalman = Kalman(self.model, noise_variance * np.eye(2)) 85 | state_covariance_matrices = [ 86 | state_variance * np.eye(1) for _ in range(n_steps) 87 | ] 88 | 89 | output_standard_deviations = kalman._measurement_and_state_standard_deviation(state_covariance_matrices) 90 | 91 | self.assertEqual(n_steps, len(output_standard_deviations)) 92 | for output_standard_deviation in output_standard_deviations: 93 | self.assertTrue(np.isclose(5, output_standard_deviation)) 94 | 95 | def test_list_of_states_to_array(self): 96 | list_of_states = [ 97 | np.array([[1], [2]]), 98 | np.array([[3], [4]]) 99 | ] 100 | result = Kalman._list_of_states_to_array(list_of_states) 101 | self.assertTrue(np.all(np.isclose( 102 | np.array([ 103 | [1, 2], 104 | [3, 4] 105 | ]), 106 | result 107 | ))) 108 | 109 | def test_to_dataframe(self): 110 | kalman = Kalman(self.model, np.eye(2)) 111 | kalman.us = [ 112 | np.array([[1]]), 113 | np.array([[1]]), 114 | ] 115 | kalman.ys = [ 116 | np.array([[1]]), 117 | np.array([[3]]), 118 | ] 119 | kalman.y_filtereds = [ 120 | np.array([[11]]), 121 | np.array([[13]]), 122 | ] 123 | kalman.y_predicteds = [ 124 | np.array([[21]]), 125 | np.array([[23]]), 126 | ] 127 | kalman.p_filtereds = 2 * [np.eye(1)] 128 | kalman.p_predicteds = 2 * [np.eye(1)] 129 | df = kalman.to_dataframe() 130 | 131 | self.assertTrue(np.all(np.isclose( 132 | np.array([1, 3]), 133 | df[('$y_0$', kalman.actual_label, kalman.output_label)].to_numpy() 134 | ))) 135 | self.assertTrue(np.all(np.isclose( 136 | np.array([11, 13]), 137 | df[('$y_0$', kalman.filtered_label, kalman.output_label)].to_numpy() 138 | ))) 139 | self.assertTrue(np.all(np.isclose( 140 | np.array([21, 23]), 141 | df[('$y_0$', kalman.next_predicted_label, kalman.output_label)].to_numpy() 142 | ))) 143 | self.assertTrue(np.all(np.isclose( 144 | np.array([21.8]), 145 | df[('$y_0$', kalman.next_predicted_corrected_label, kalman.output_label)].to_numpy()[:-1] 146 | ))) 147 | self.assertTrue(np.isnan( 148 | df[('$y_0$', kalman.next_predicted_corrected_label, kalman.output_label)].iloc[-1] 149 | )) 150 | 151 | 152 | def is_slightly_close(matrix, number): 153 | return np.isclose(matrix, number, rtol=0, atol=1e-3) 154 | -------------------------------------------------------------------------------- /src/nfoursid/state_space.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from matplotlib import pyplot as plt 6 | from matplotlib import figure as matplotlib_figure 7 | 8 | from nfoursid.utils import Utils 9 | 10 | 11 | class StateSpace: 12 | r""" 13 | A state-space model defined by the following equations: 14 | 15 | .. math:: 16 | \begin{cases} 17 | x_{k+1} &= A x_k + B u_k + K e_k \\ 18 | y_k &= C x_k + D u_k + e_k 19 | \end{cases} 20 | 21 | The shapes of the matrices are checked for consistency and will raise if inconsistent. 22 | If a matrix does not exist in the model representation, the corresponding ``np.ndarray`` should have dimension 23 | zero along that axis. See the example below. 24 | 25 | Example 26 | ------- 27 | An autonomous state-space model has no matrices :math:`B` and :math:`D`. 28 | An autonomous model with a one-dimensional internal state and output, can be represented as follows: 29 | 30 | >>> model = StateSpace( 31 | >>> np.ones((1, 1)), 32 | >>> np.ones((1, 0)), 33 | >>> np.ones((1, 1)), 34 | >>> np.ones((1, 0)) 35 | >>> ) 36 | 37 | :param a: matrix :math:`A` 38 | :param b: matrix :math:`B` 39 | :param c: matrix :math:`C` 40 | :param d: matrix :math:`D` 41 | :param k: matrix :math:`K`, optional 42 | :param x_init: initial state :math:`x_0` of the model, optional 43 | :param y_column_names: list of output column names, optional 44 | :param u_column_names: list of input column names, optional 45 | """ 46 | def __init__( 47 | self, 48 | a: np.ndarray, 49 | b: np.ndarray, 50 | c: np.ndarray, 51 | d: np.ndarray, 52 | k: np.ndarray = None, 53 | x_init: np.ndarray = None, 54 | y_column_names: List[str] = None, 55 | u_column_names: List[str] = None 56 | ): 57 | self._set_dimensions(a, b, c) 58 | self._set_x_init(x_init) 59 | self._set_column_names(u_column_names, y_column_names) 60 | self._set_matrices(a, b, c, d, k) 61 | 62 | def _set_dimensions( 63 | self, 64 | a: np.ndarray, 65 | b: np.ndarray, 66 | c: np.ndarray 67 | ): 68 | """ Determine the dimensions of the internal states, outputs and inputs, based on the matrix shapes. """ 69 | self.x_dim = a.shape[0] 70 | self.y_dim = c.shape[0] 71 | self.u_dim = b.shape[1] 72 | 73 | if self.y_dim == 0: 74 | raise ValueError('The dimension of the output should be at least 1') 75 | 76 | def _set_matrices( 77 | self, 78 | a: np.ndarray, 79 | b: np.ndarray, 80 | c: np.ndarray, 81 | d: np.ndarray, 82 | k: np.ndarray 83 | ): 84 | """ Validate if the shapes make sense and set the system matrices. """ 85 | if k is None: 86 | k = np.zeros((self.x_dim, self.y_dim)) 87 | Utils.validate_matrix_shape(a, (self.x_dim, self.x_dim), 'a') 88 | Utils.validate_matrix_shape(b, (self.x_dim, self.u_dim), 'b') 89 | Utils.validate_matrix_shape(c, (self.y_dim, self.x_dim), 'c') 90 | Utils.validate_matrix_shape(d, (self.y_dim, self.u_dim), 'd') 91 | Utils.validate_matrix_shape(k, (self.x_dim, self.y_dim), 'k') 92 | self.a = a 93 | self.b = b 94 | self.c = c 95 | self.d = d 96 | self.k = k 97 | self.xs = [] 98 | self.ys = [] 99 | self.us = [] 100 | 101 | def _set_column_names( 102 | self, 103 | u_column_names: List[str], 104 | y_column_names: List[str] 105 | ): 106 | """ Set the column names of the input and output. """ 107 | if y_column_names is None: 108 | y_column_names = [f'$y_{i}$' for i in range(self.y_dim)] 109 | if u_column_names is None: 110 | u_column_names = [f'$u_{i}$' for i in range(self.u_dim)] 111 | if len(y_column_names) != self.y_dim: 112 | raise ValueError(f'Length of `y_column_names` should be {self.y_dim}, not {len(y_column_names)}') 113 | if len(u_column_names) != self.u_dim: 114 | raise ValueError(f'Length of `u_column_names` should be {self.u_dim}, not {len(u_column_names)}') 115 | self.y_column_names = y_column_names 116 | self.u_column_names = u_column_names 117 | 118 | def _set_x_init(self, x_init: np.ndarray): 119 | """ Set the initial state, if it is given. """ 120 | if x_init is None: 121 | x_init = np.zeros((self.x_dim, 1)) 122 | Utils.validate_matrix_shape(x_init, (self.x_dim, 1), 'x_dim') 123 | self._x_init = x_init 124 | 125 | def step( 126 | self, 127 | u: np.ndarray = None, 128 | e: np.ndarray = None 129 | ) -> np.ndarray: 130 | """ 131 | Calculates the output of the state-space model and returns it. 132 | Updates the internal state of the model as well. 133 | The input ``u`` is optional, as is the noise ``e``. 134 | """ 135 | if u is None: 136 | u = np.zeros((self.u_dim, 1)) 137 | if e is None: 138 | e = np.zeros((self.y_dim, 1)) 139 | 140 | Utils.validate_matrix_shape(u, (self.u_dim, 1), 'u') 141 | Utils.validate_matrix_shape(e, (self.y_dim, 1), 'e') 142 | 143 | x = self.xs[-1] if self.xs else self._x_init 144 | x, y = ( 145 | self.a @ x + self.b @ u + self.k @ e, 146 | self.output(x, u, e) 147 | ) 148 | self.us.append(u) 149 | self.xs.append(x) 150 | self.ys.append(y) 151 | return y 152 | 153 | def output( 154 | self, 155 | x: np.ndarray, 156 | u: np.ndarray = None, 157 | e: np.ndarray = None): 158 | """ 159 | Calculate the output of the state-space model. 160 | This function calculates the updated :math:`y_k` of the state-space model in the class description. 161 | The current state ``x`` is required. 162 | Providing an input ``u`` is optional. 163 | Providing a noise term ``e`` to be added is optional as well. 164 | """ 165 | if u is None: 166 | u = np.zeros((self.u_dim, 1)) 167 | if e is None: 168 | e = np.zeros((self.y_dim, 1)) 169 | 170 | Utils.validate_matrix_shape(x, (self.x_dim, 1), 'x') 171 | Utils.validate_matrix_shape(u, (self.u_dim, 1), 'u') 172 | Utils.validate_matrix_shape(e, (self.y_dim, 1), 'e') 173 | 174 | return self.c @ x + self.d @ u + e 175 | 176 | def plot_input_output(self, fig: matplotlib_figure.Figure): # pragma: no cover 177 | """ 178 | Given a matplotlib figure ``fig``, plot the inputs and outputs of the state-space model. 179 | """ 180 | ax1 = fig.add_subplot(2, 1, 1) 181 | ax2 = fig.add_subplot(2, 1, 2, sharex=ax1) 182 | 183 | for output_name, outputs in zip(self.y_column_names, np.array(self.ys).squeeze(axis=2).T): 184 | ax1.plot(outputs, label=output_name, alpha=.6) 185 | ax1.legend(loc='upper right') 186 | ax1.set_ylabel('Output $y$ (a.u.)') 187 | ax1.grid() 188 | 189 | for input_name, inputs in zip(self.u_column_names, np.array(self.us).squeeze(axis=2).T): 190 | ax2.plot(inputs, label=input_name, alpha=.6) 191 | ax2.legend(loc='upper right') 192 | ax2.set_ylabel('Input $u$ (a.u.)') 193 | ax2.set_xlabel('Index') 194 | ax2.grid() 195 | 196 | ax1.set_title('Inputs and outputs of state-space model') 197 | plt.setp(ax1.get_xticklabels(), visible=False) 198 | 199 | def to_dataframe(self) -> pd.DataFrame: 200 | """ 201 | Return the inputs and outputs of the state-space model as a dataframe, where the columns are the input- 202 | and output-columns. 203 | """ 204 | inputs_df = pd.DataFrame(np.array(self.us).squeeze(axis=2), columns=self.u_column_names) 205 | outputs_df = pd.DataFrame(np.array(self.ys).squeeze(axis=2), columns=self.y_column_names) 206 | return pd.concat([inputs_df, outputs_df], axis=1) 207 | -------------------------------------------------------------------------------- /src/nfoursid/nfoursid.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from nfoursid.state_space import StateSpace 8 | from nfoursid.utils import Utils, Decomposition 9 | 10 | 11 | class NFourSID: 12 | r""" 13 | Perform subspace identification using N4SID [1]. 14 | The state-space model under consideration, is: 15 | 16 | .. math:: 17 | \begin{cases} 18 | x_{k+1} &= A x_k + B u_k + K e_k \\ 19 | y_k &= C x_k + D u_k + e_k 20 | \end{cases} 21 | 22 | Data is provided as a dataframe ``dataframe`` where every row is a measurement. 23 | The output columns are given by ``output_columns``. 24 | The input columns are given by ``input_columns``. 25 | 26 | The number of block rows to use in the block Hankel matrices is given by ``num_block_rows``. 27 | If ``num_block_rows`` is chosen to be too big, the computational complexity will increase. 28 | If ``num_block_rows`` is chosen to be too small, the order of the system might not be possible to determine 29 | in the eigenvalue diagram. Moreover, if ``num_block_rows`` is chosen to be too small, 30 | the assumptions of [2] might not hold. 31 | 32 | [1] Van Overschee, Peter, and Bart De Moor. "N4SID: Subspace algorithms for the identification of combined 33 | deterministic-stochastic systems." Automatica 30.1 (1994): 75-93. 34 | """ 35 | def __init__( 36 | self, 37 | dataframe: pd.DataFrame, 38 | output_columns: List[str], 39 | input_columns: List[str] = None, 40 | num_block_rows: int = 2 41 | ): 42 | self.u_columns = input_columns or [] 43 | self.y_columns = output_columns 44 | self.num_block_rows = num_block_rows 45 | 46 | self._set_input_output_data(dataframe) 47 | self._initialize_instance_variables() 48 | 49 | def _initialize_instance_variables(self): 50 | """ Initialize variables. """ 51 | self.R22, self.R32 = None, None 52 | self.R32_decomposition = None 53 | self.x_dim = None 54 | 55 | def _set_input_output_data( 56 | self, 57 | dataframe: pd.DataFrame 58 | ): 59 | """ Perform data consistency checks and set timeseries data arrays. """ 60 | u_frame = dataframe[self.u_columns] 61 | if u_frame.isnull().any().any(): 62 | raise ValueError('Input data cannot contain nulls') 63 | y_frame = dataframe[self.y_columns] 64 | if y_frame.isnull().any().any(): 65 | raise ValueError('Output data cannot contain nulls') 66 | self.u_array = u_frame.to_numpy() 67 | self.y_array = y_frame.to_numpy() 68 | self.u_dim = self.u_array.shape[1] 69 | self.y_dim = self.y_array.shape[1] 70 | 71 | def subspace_identification(self): 72 | """ 73 | Perform subspace identification based on the PO-MOESP method. 74 | The instrumental variable contains past outputs and past inputs. 75 | The implementation uses a QR-decomposition for numerical efficiency and is based on page 329 of [1]. 76 | 77 | A key result of this function is the eigenvalue decomposition of the :math:`R_{32}` matrix 78 | ``self.R32_decomposition``, based on which the order of the system should be determined. 79 | 80 | [1] Verhaegen, Michel, and Vincent Verdult. *Filtering and system identification: a least squares approach.* 81 | Cambridge university press, 2007. 82 | """ 83 | u_hankel = Utils.block_hankel_matrix(self.u_array, self.num_block_rows) 84 | y_hankel = Utils.block_hankel_matrix(self.y_array, self.num_block_rows) 85 | 86 | u_past, u_future = u_hankel[:, :-self.num_block_rows], u_hankel[:, self.num_block_rows:] 87 | y_past, y_future = y_hankel[:, :-self.num_block_rows], y_hankel[:, self.num_block_rows:] 88 | u_instrumental_y = np.concatenate([u_future, u_past, y_past, y_future]) 89 | 90 | q, r = map(lambda matrix: matrix.T, np.linalg.qr(u_instrumental_y.T, mode='reduced')) 91 | 92 | y_rows, u_rows = self.y_dim * self.num_block_rows, self.u_dim * self.num_block_rows 93 | self.R32 = r[-y_rows:, u_rows:-y_rows] 94 | self.R22 = r[u_rows:-y_rows, u_rows:-y_rows] 95 | self.R32_decomposition = Utils.eigenvalue_decomposition(self.R32) 96 | 97 | def system_identification( 98 | self, 99 | rank: int = None 100 | ) -> Tuple[StateSpace, np.ndarray]: 101 | """ 102 | Identify the system matrices of the state-space model given in the description of ``NFourSID``. 103 | Moreover, the covariance of the measurement-noise and process-noise will be estimated. 104 | The order of the returned state-space model has rank ``rank`` by reducing the eigenvalue decomposition. 105 | The implementation is based on page 333 of [1]. 106 | 107 | The return value consists of a tuple containing 108 | 109 | - The identified state-space model containing the estimated matrices :math:`(A, B, C, D)`, 110 | - and an estimate of the covariance matrix of the measurement-noise :math:`w` 111 | and process-noise :math:`v`. 112 | The structure of the covariance matrix corresponds to the parameter ``noise_covariance`` of 113 | ``subspace_identification.kalman.Kalman``. 114 | See its documentation for more information. 115 | 116 | ``self.system_identification`` needs the QR-decomposition result of subspace identification 117 | ``self.R32``, and therefore can only be ran after ``self.subspace_identification``. 118 | 119 | [1] Verhaegen, Michel, and Vincent Verdult. *Filtering and system identification: a least squares approach.* 120 | Cambridge university press, 2007. 121 | """ 122 | if self.R32_decomposition is None: 123 | raise Exception('Perform subspace identification first.') 124 | if rank is None: 125 | rank = self.y_dim * self.num_block_rows 126 | self.x_dim = rank 127 | 128 | observability_decomposition = self._get_observability_matrix_decomposition() 129 | 130 | return self._identify_state_space(observability_decomposition) 131 | 132 | def _identify_state_space( 133 | self, 134 | observability_decomposition: Decomposition 135 | ) -> Tuple[StateSpace, np.ndarray]: 136 | """ 137 | Approximate the row space of the state sequence of a Kalman filter as per the N4SID scheme. 138 | Then, solve a least squares problem to identify the system matrices. 139 | Finally, use the residuals to estimate the noise covariance matrix. 140 | """ 141 | x = (np.power(observability_decomposition.eigenvalues, .5) 142 | @ observability_decomposition.right_orthogonal)[:, :-1] 143 | last_y, last_u = self.y_array[self.num_block_rows:, :].T, self.u_array[self.num_block_rows:, :].T 144 | x_and_y = np.concatenate([x[:, 1:], 145 | last_y[:, :-1]]) 146 | x_and_u = np.concatenate([x[:, :-1], 147 | last_u[:, :-1]]) 148 | abcd = (np.linalg.pinv(x_and_u @ x_and_u.T) @ x_and_u @ x_and_y.T).T 149 | residuals = x_and_y - abcd @ x_and_u 150 | covariance_matrix = residuals @ residuals.T / residuals.shape[1] 151 | q = covariance_matrix[:self.x_dim, :self.x_dim] 152 | r = covariance_matrix[self.x_dim:, self.x_dim:] 153 | s = covariance_matrix[:self.x_dim, self.x_dim:] 154 | state_space_covariance_matrix = np.concatenate( 155 | [ 156 | np.concatenate([r, s.T], axis=1), 157 | np.concatenate([s, q], axis=1) 158 | ], 159 | axis=0 160 | ) 161 | return ( 162 | StateSpace( 163 | abcd[:self.x_dim, :self.x_dim], 164 | abcd[:self.x_dim, self.x_dim:], 165 | abcd[self.x_dim:, :self.x_dim], 166 | abcd[self.x_dim:, self.x_dim:], 167 | ), 168 | (state_space_covariance_matrix + state_space_covariance_matrix.T) / 2 169 | ) 170 | 171 | def _get_observability_matrix_decomposition(self) -> Decomposition: 172 | """ 173 | Calculate the eigenvalue decomposition of the estimate of the observability matrix as per N4SID. 174 | """ 175 | u_hankel = Utils.block_hankel_matrix(self.u_array, self.num_block_rows) 176 | y_hankel = Utils.block_hankel_matrix(self.y_array, self.num_block_rows) 177 | u_and_y = np.concatenate([u_hankel, y_hankel]) 178 | observability = self.R32 @ np.linalg.pinv(self.R22) @ u_and_y 179 | observability_decomposition = Utils.reduce_decomposition( 180 | Utils.eigenvalue_decomposition(observability), 181 | self.x_dim 182 | ) 183 | return observability_decomposition 184 | 185 | def plot_eigenvalues(self, ax: plt.axes): # pragma: no cover 186 | """ 187 | Plot the eigenvalues of the :math:`R_{32}` matrix, so that the order of the state-space model can be determined. 188 | Since the :math:`R_{32}` matrix should have been calculated, this function can only be used after 189 | performing ``self.subspace_identification``. 190 | """ 191 | if self.R32_decomposition is None: 192 | raise Exception('Perform subspace identification first.') 193 | 194 | ax.semilogy(np.diagonal(self.R32_decomposition.eigenvalues), 'x') 195 | ax.set_title('Estimated observability matrix decomposition') 196 | ax.set_xlabel('Index') 197 | ax.set_ylabel('Eigenvalue') 198 | ax.grid() 199 | -------------------------------------------------------------------------------- /src/nfoursid/kalman.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from matplotlib import figure as matplotlib_figure 4 | import numpy as np 5 | import pandas as pd 6 | from matplotlib import pyplot as plt 7 | 8 | from nfoursid.state_space import StateSpace 9 | from nfoursid.utils import Utils 10 | 11 | 12 | class Kalman: 13 | r""" 14 | Implementation [1] of a Kalman filter for a state-space model ``state_space``: 15 | 16 | .. math:: 17 | \begin{cases} 18 | x_{k+1} &= A x_k + B u_k + w_k \\ 19 | y_k &= C x_k + D u_k + v_k 20 | \end{cases} 21 | 22 | The matrices :math:`(A, B, C, D)` are taken from the state-space model ``state_space``. 23 | The measurement-noise :math:`v_k` and process-noise :math:`w_k` have a covariance matrix 24 | ``noise_covariance`` defined as 25 | 26 | .. math:: 27 | \texttt{noise\_covariance} := \mathbb{E} \bigg ( 28 | \begin{bmatrix} 29 | v \\ w 30 | \end{bmatrix} 31 | \begin{bmatrix} 32 | v \\ w 33 | \end{bmatrix}^\mathrm{T} 34 | \bigg ) 35 | 36 | [1] Verhaegen, Michel, and Vincent Verdult. *Filtering and system identification: a least squares approach.* 37 | Cambridge university press, 2007. 38 | """ 39 | output_label = 'output' 40 | """ Label given to an output column in ``self.to_dataframe``. """ 41 | standard_deviation_label = 'standard deviation' 42 | """ Label given to a standard deviation column in ``self.to_dataframe``. """ 43 | actual_label = 'actual' 44 | """ Label given to a column in ``self.to_dataframe``, indicating measured values. """ 45 | filtered_label = 'filtered' 46 | """ Label given to a column in ``self.to_dataframe``, indicating the filtered state of the Kalman filter. """ 47 | next_predicted_label = 'next predicted (no input)' 48 | """ 49 | Label given to a column in ``self.to_dataframe``, indicating the predicted state of the Kalman filter under the 50 | absence of further inputs. 51 | """ 52 | next_predicted_corrected_label = 'next predicted (input corrected)' 53 | """ 54 | Label given to a column in ``self.to_dataframe``, indicating the predicted state of the Kalman filter corrected 55 | by previous inputs. The inputs to the state-space model are known, but not at the time that the prediction was 56 | made. In order to make a fair comparison for prediction performance, the direct effect of the input on the output 57 | by the matrix :math:`D` is removed in this column. 58 | 59 | The latest prediction will have ``np.nan`` in this column, since the input is not yet known. 60 | """ 61 | 62 | def __init__( 63 | self, 64 | state_space: StateSpace, 65 | noise_covariance: np.ndarray 66 | ): 67 | self.state_space = state_space 68 | 69 | Utils.validate_matrix_shape( 70 | noise_covariance, 71 | (self.state_space.y_dim + self.state_space.x_dim, 72 | self.state_space.y_dim + self.state_space.x_dim), 73 | 'noise_covariance') 74 | self.r = noise_covariance[:self.state_space.y_dim, :self.state_space.y_dim] 75 | self.s = noise_covariance[self.state_space.y_dim:, :self.state_space.y_dim] 76 | self.q = noise_covariance[self.state_space.y_dim:, self.state_space.y_dim:] 77 | 78 | self.x_filtereds = [] 79 | self.x_predicteds = [] 80 | self.p_filtereds = [] 81 | self.p_predicteds = [] 82 | self.us = [] 83 | self.ys = [] 84 | self.y_filtereds = [] 85 | self.y_predicteds = [] 86 | self.kalman_gains = [] 87 | 88 | def step( 89 | self, 90 | y: Optional[np.ndarray], 91 | u: np.ndarray 92 | ): 93 | """ 94 | Given an observed input ``u`` and output ``y``, update the filtered and predicted states of the Kalman filter. 95 | Follows the implementation of the conventional Kalman filter in [1] on page 140. 96 | 97 | The output ``y`` can be missing by setting ``y=None``. 98 | In that case, the Kalman filter will obtain the next internal state by stepping the state space model. 99 | 100 | [1] Verhaegen, Michel, and Vincent Verdult. *Filtering and system identification: a least squares approach.* 101 | Cambridge university press, 2007. 102 | """ 103 | if y is not None: 104 | Utils.validate_matrix_shape(y, (self.state_space.y_dim, 1), 'y') 105 | Utils.validate_matrix_shape(u, (self.state_space.u_dim, 1), 'u') 106 | 107 | x_pred = self.x_predicteds[-1] if self.x_predicteds else np.zeros((self.state_space.x_dim, 1)) 108 | p_pred = self.p_predicteds[-1] if self.p_predicteds else np.eye(self.state_space.x_dim) 109 | 110 | k_filtered = p_pred @ self.state_space.c.T @ np.linalg.pinv( 111 | self.r + self.state_space.c @ p_pred @ self.state_space.c.T 112 | ) 113 | 114 | self.p_filtereds.append( 115 | p_pred - k_filtered @ self.state_space.c @ p_pred 116 | ) 117 | 118 | self.x_filtereds.append( 119 | x_pred + k_filtered @ (y - self.state_space.d @ u - self.state_space.c @ x_pred) 120 | if y is not None else x_pred 121 | ) 122 | 123 | k_pred = (self.s + self.state_space.a @ p_pred @ self.state_space.c.T) @ np.linalg.pinv( 124 | self.r + self.state_space.c @ p_pred @ self.state_space.c.T 125 | ) 126 | 127 | self.p_predicteds.append( 128 | self.state_space.a @ p_pred @ self.state_space.a.T 129 | + self.q 130 | - k_pred @ (self.s + self.state_space.a @ p_pred @ self.state_space.c.T).T 131 | ) 132 | 133 | x_predicted = self.state_space.a @ x_pred + self.state_space.b @ u 134 | if y is not None: 135 | x_predicted += k_pred @ (y - self.state_space.d @ u - self.state_space.c @ x_pred) 136 | self.x_predicteds.append( 137 | x_predicted 138 | ) 139 | 140 | self.us.append(u) 141 | self.ys.append(y if y is not None else np.full((self.state_space.y_dim, 1), np.nan)) 142 | self.y_filtereds.append(self.state_space.output(self.x_filtereds[-1], self.us[-1])) 143 | self.y_predicteds.append(self.state_space.output(self.x_predicteds[-1])) 144 | self.kalman_gains.append(k_pred) 145 | 146 | return self.y_filtereds[-1], self.y_predicteds[-1] 147 | 148 | def extrapolate( 149 | self, 150 | timesteps 151 | ) -> pd.DataFrame: 152 | """ 153 | Make a ``timesteps`` number of steps ahead prediction about the output of the state-space model 154 | ``self.state_space`` given no further inputs. 155 | The result is a ``pd.DataFrame`` where the columns are ``self.state_space.y_column_names``: 156 | the output columns of the state-space model ``self.state_space``. 157 | """ 158 | if not self.x_predicteds: 159 | raise Exception('Prediction is only possible once Kalman estimation has been performed.') 160 | 161 | state_space = StateSpace( 162 | self.state_space.a, 163 | self.state_space.b, 164 | self.state_space.c, 165 | self.state_space.d, 166 | x_init=self.x_predicteds[-1], 167 | y_column_names=self.state_space.y_column_names, 168 | u_column_names=self.state_space.u_column_names 169 | ) 170 | 171 | for _ in range(timesteps): 172 | state_space.step() 173 | 174 | return state_space.to_dataframe()[state_space.y_column_names] 175 | 176 | def _measurement_and_state_standard_deviation( 177 | self, 178 | state_covariance_matrices: List[np.ndarray] 179 | ) -> List[np.ndarray]: 180 | """ 181 | Calculates the expected standard deviations on the output, assuming independence (!) in the noise of the state 182 | estimate and the process noise. 183 | Returns a list of row-vectors containing the standard deviations for the outputs. 184 | """ 185 | covars_process_y = [ 186 | self.state_space.c @ p @ self.state_space.c.T for p in state_covariance_matrices 187 | ] 188 | 189 | var_process_ys = [ 190 | np.maximum( 191 | np.diagonal(p), 0 192 | ) 193 | for p in covars_process_y 194 | ] 195 | var_measurement_y = np.maximum(np.diagonal(self.r), 0) 196 | 197 | return [ 198 | np.sqrt( 199 | var_process_y + var_measurement_y 200 | ).reshape( 201 | (self.state_space.y_dim, 1) 202 | ) 203 | for var_process_y in var_process_ys 204 | ] 205 | 206 | @staticmethod 207 | def _list_of_states_to_array( 208 | list_of_states: List[np.ndarray] 209 | ) -> np.ndarray: 210 | return np.array(list_of_states).squeeze(axis=2) 211 | 212 | @staticmethod 213 | def _reduce_dimension(element): 214 | return element[0] 215 | 216 | def to_dataframe(self) -> pd.DataFrame: 217 | """ 218 | Returns the output of the Kalman filter as a ``pd.DataFrame``. The returned value contains information about 219 | filtered and predicted states of the Kalman filter at different timesteps. 220 | The expected standard deviation of the output is given, assuming independence (!) of the state estimation error 221 | and measurement noise. 222 | 223 | The rows of the returned dataframe correspond to timesteps. 224 | The columns of the returned dataframe are a 3-dimensional multi-index with the following levels: 225 | 226 | 1. The output name, in the list ``self.state_space.y_column_names``. 227 | 2. An indication of whether the value is 228 | - a value that was actually measured, these values were given to `self.step` as the `y` parameter, 229 | - a filtered state, 230 | - a predicted state given no further input or 231 | - a predicted state where the effect of the next input has been corrected for. 232 | This column is useful for comparing prediction performance. 233 | 3. Whether the column is a value or the corresponding expected standard deviation. 234 | """ 235 | input_corrected_predictions = [ 236 | output + self.state_space.d @ input_state 237 | for input_state, output 238 | in zip(self.us[1:], self.y_predicteds[:-1]) 239 | ] + [np.empty((self.state_space.y_dim, 1)) * np.nan] 240 | 241 | output_frames = [ 242 | pd.DataFrame({ 243 | (self.actual_label, self.output_label): map(self._reduce_dimension, outputs), 244 | (self.filtered_label, self.output_label): map(self._reduce_dimension, filtereds), 245 | (self.filtered_label, self.standard_deviation_label): map(self._reduce_dimension, filtered_stds), 246 | (self.next_predicted_label, self.output_label): map(self._reduce_dimension, predicteds), 247 | (self.next_predicted_label, self.standard_deviation_label): map(self._reduce_dimension, predicted_stds), 248 | (self.next_predicted_corrected_label, self.output_label): map(self._reduce_dimension, input_corrected_prediction), 249 | (self.next_predicted_corrected_label, self.standard_deviation_label): map(self._reduce_dimension, predicted_stds), 250 | }) 251 | for ( 252 | outputs, 253 | filtereds, 254 | predicteds, 255 | filtered_stds, 256 | predicted_stds, 257 | input_corrected_prediction 258 | ) in zip( 259 | zip(*self.ys), 260 | zip(*self.y_filtereds), 261 | zip(*self.y_predicteds), 262 | zip(*self._measurement_and_state_standard_deviation(self.p_filtereds)), 263 | zip(*self._measurement_and_state_standard_deviation(self.p_predicteds)), 264 | zip(*input_corrected_predictions) 265 | ) 266 | ] 267 | return pd.concat(output_frames, axis=1, keys=self.state_space.y_column_names) 268 | 269 | def plot_filtered(self, fig: matplotlib_figure.Figure): # pragma: no cover 270 | """ 271 | The top graph plots the filtered output states of the Kalman filter and compares with the measured values. 272 | The error bars correspond to the expected standard deviations. 273 | The bottom graph zooms in on the errors between the filtered states and the measured values, compared with 274 | the expected standard deviations. 275 | """ 276 | ax1 = fig.add_subplot(2, 1, 1) 277 | ax2 = fig.add_subplot(2, 1, 2, sharex=ax1) 278 | 279 | df = self.to_dataframe() 280 | 281 | top_legends, bottom_legends = [], [] 282 | for output_name in self.state_space.y_column_names: 283 | actual_outputs = df[(output_name, self.actual_label, self.output_label)] 284 | predicted_outputs = df[(output_name, self.filtered_label, self.output_label)] 285 | std = df[(output_name, self.filtered_label, self.standard_deviation_label)] 286 | 287 | markers, = ax1.plot( 288 | list(range(len(actual_outputs))), 289 | actual_outputs, 290 | 'x' 291 | ) 292 | line, = ax1.plot( 293 | list(range(len(actual_outputs))), 294 | actual_outputs, 295 | '-', 296 | color=markers.get_color(), 297 | alpha=.15 298 | ) 299 | top_legends.append(((markers, line), output_name)) 300 | 301 | prediction_errorbar = ax1.errorbar( 302 | list(range(len(predicted_outputs))), 303 | predicted_outputs, 304 | yerr=std, 305 | marker='_', 306 | alpha=.5, 307 | color=markers.get_color(), 308 | markersize=10, 309 | linestyle='', 310 | capsize=3 311 | ) 312 | top_legends.append((prediction_errorbar, f'Filtered {output_name}')) 313 | 314 | errors = actual_outputs - predicted_outputs 315 | markers_bottom, = ax2.plot( 316 | list(range(len(errors))), 317 | errors, 318 | 'x' 319 | ) 320 | lines_bottom, = ax2.plot( 321 | list(range(len(errors))), 322 | errors, 323 | '-', 324 | color=markers_bottom.get_color(), 325 | alpha=.15 326 | ) 327 | bottom_legends.append(((markers_bottom, lines_bottom), f'Error {output_name}')) 328 | prediction_errorbar_bottom, = ax2.plot( 329 | list(range(len(predicted_outputs))), 330 | std, 331 | '--', 332 | alpha=.5, 333 | color=markers.get_color(), 334 | ) 335 | ax2.plot( 336 | list(range(len(predicted_outputs))), 337 | -std, 338 | '--', 339 | alpha=.5, 340 | color=markers.get_color(), 341 | ) 342 | bottom_legends.append((prediction_errorbar_bottom, rf'Filtered $\sigma(${output_name}$)$')) 343 | 344 | lines, names = zip(*top_legends) 345 | ax1.legend(lines, names, loc='upper left') 346 | ax1.set_ylabel('Output $y$ (a.u.)') 347 | ax1.grid() 348 | 349 | lines, names = zip(*bottom_legends) 350 | ax2.legend(lines, names, loc='upper left') 351 | ax2.set_xlabel('Index') 352 | ax2.set_ylabel(r'Filtering error $y-y_{\mathrm{filtered}}$ (a.u.)') 353 | ax2.grid() 354 | ax1.set_title('Kalman filter, filtered state') 355 | plt.setp(ax1.get_xticklabels(), visible=False) 356 | 357 | def plot_predicted( 358 | self, 359 | fig: matplotlib_figure.Figure, 360 | steps_to_extrapolate: int = 1 361 | ): # pragma: no cover 362 | """ 363 | The top graph plots the predicted output states of the Kalman filter and compares with the measured values. 364 | The error bars correspond to the expected standard deviations. 365 | 366 | The stars on the top right represent the ``steps_to_extrapolate``-steps ahead extrapolation under no further 367 | inputs. The bottom graph zooms in on the errors between the predicted states and the measured values, compared 368 | with the expected standard deviations. 369 | """ 370 | ax1 = fig.add_subplot(2, 1, 1) 371 | ax2 = fig.add_subplot(2, 1, 2, sharex=ax1) 372 | 373 | df = self.to_dataframe() 374 | 375 | extrapolation = self.extrapolate(steps_to_extrapolate) 376 | 377 | top_legends, bottom_legends = [], [] 378 | for output_name in self.state_space.y_column_names: 379 | actual_outputs = df[(output_name, self.actual_label, self.output_label)] 380 | predicted_outputs = df[(output_name, self.next_predicted_corrected_label, self.output_label)] 381 | std = df[(output_name, self.next_predicted_label, self.standard_deviation_label)] 382 | last_predicted_std = std.iloc[-1] 383 | 384 | markers, = ax1.plot( 385 | list(range(len(actual_outputs))), 386 | actual_outputs, 387 | 'x' 388 | ) 389 | line, = ax1.plot( 390 | list(range(len(actual_outputs))), 391 | actual_outputs, 392 | '-', 393 | color=markers.get_color(), 394 | alpha=.15 395 | ) 396 | top_legends.append(((markers, line), output_name)) 397 | 398 | prediction_errorbar = ax1.errorbar( 399 | list(range(1, len(predicted_outputs)+1)), 400 | predicted_outputs, 401 | yerr=std, 402 | marker='_', 403 | alpha=.5, 404 | color=markers.get_color(), 405 | markersize=10, 406 | linestyle='', 407 | capsize=3 408 | ) 409 | top_legends.append((prediction_errorbar, f'Predicted {output_name}')) 410 | extrapolation_errorbar = ax1.errorbar( 411 | list(range(len(self.ys), len(self.ys) + steps_to_extrapolate)), 412 | extrapolation[output_name].to_numpy(), 413 | yerr=last_predicted_std, 414 | marker='*', 415 | markersize=9, 416 | alpha=.8, 417 | color=markers.get_color(), 418 | linestyle='', 419 | capsize=3 420 | ) 421 | top_legends.append((extrapolation_errorbar, f'Extrapolation {output_name} (no input)')) 422 | 423 | errors = actual_outputs.to_numpy()[1:] - predicted_outputs.to_numpy()[:-1] 424 | markers_bottom, = ax2.plot( 425 | list(range(1, len(errors)+1)), 426 | errors, 427 | 'x' 428 | ) 429 | lines_bottom, = ax2.plot( 430 | list(range(1, len(errors)+1)), 431 | errors, 432 | '-', 433 | color=markers_bottom.get_color(), 434 | alpha=.15 435 | ) 436 | bottom_legends.append(((markers_bottom, lines_bottom), f'Error {output_name}')) 437 | prediction_errorbar_bottom, = ax2.plot( 438 | list(range(1, len(predicted_outputs))), 439 | std[:-1], 440 | '--', 441 | alpha=.5, 442 | color=markers.get_color(), 443 | ) 444 | ax2.plot( 445 | list(range(1, len(predicted_outputs))), 446 | -std[:-1], 447 | '--', 448 | alpha=.5, 449 | color=markers.get_color(), 450 | ) 451 | bottom_legends.append((prediction_errorbar_bottom, rf'Predicted $\sigma(${output_name}$)$')) 452 | 453 | lines, names = zip(*top_legends) 454 | ax1.legend(lines, names, loc='upper left') 455 | ax1.set_ylabel('Output $y$ (a.u.)') 456 | ax1.grid() 457 | 458 | lines, names = zip(*bottom_legends) 459 | ax2.legend(lines, names, loc='upper left') 460 | ax2.set_xlabel('Index') 461 | ax2.set_ylabel(r'Prediction error $y-y_{\mathrm{predicted}}$ (a.u.)') 462 | ax2.grid() 463 | ax1.set_title('Kalman filter, predicted state') 464 | plt.setp(ax1.get_xticklabels(), visible=False) --------------------------------------------------------------------------------