pax_global_header00006660000000000000000000000064146472062120014516gustar00rootroot0000000000000052 comment=6e6f4b000cdf7830635674cb89a6e823f5025784 simple-pid-2.0.1/000077500000000000000000000000001464720621200135615ustar00rootroot00000000000000simple-pid-2.0.1/.github/000077500000000000000000000000001464720621200151215ustar00rootroot00000000000000simple-pid-2.0.1/.github/workflows/000077500000000000000000000000001464720621200171565ustar00rootroot00000000000000simple-pid-2.0.1/.github/workflows/parse_changelog.py000066400000000000000000000031471464720621200226560ustar00rootroot00000000000000import re import sys def parse_changelog(contents): """ Parse the CHANGELOG.md and return a mapping from version to the section of the file corresponding to that version. :param contents: The contents of CHANGELOG.md. """ result = {} # Split the changelog into sections based on lines starting with '## ' for section in re.split(r'\n## ', contents): # Clean up each part by removing the compare links at the bottom of the file, and by # stripping whitespace section = re.split(r'\[Unreleased\]', section)[0].strip() # Add back the heading that was removed by re.split() section = '## ' + section # Parse out the version of this section. Any section which doesn't have a version (such as # the '## Unreleased' section or the top introduction) are not kept. version_match = re.search(r'## \[([0-9.]+)\].*', section) if version_match and len(version_match.groups()) >= 1: result[version_match.group(1)] = section return result if __name__ == '__main__': if len(sys.argv) < 4: print('Error: too few arguments.') sys.exit(1) changelog_path = sys.argv[1] version = sys.argv[2].removeprefix('v') output_path = sys.argv[3] with open(changelog_path, 'r') as f: changelog_sections = parse_changelog(f.read()) current_section = changelog_sections.get(version) if not current_section: print(f'Error: could not find changelog section for version {version}') sys.exit(2) with open(output_path, 'w') as output: output.write(current_section) simple-pid-2.0.1/.github/workflows/release.yml000066400000000000000000000017301464720621200213220ustar00rootroot00000000000000name: release on: push: tags: - 'v*' permissions: contents: write jobs: build: name: Release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: python -m pip install --upgrade pip build - name: Build package run: python -m build . - name: Parse changelog for release notes run: python .github/workflows/parse_changelog.py CHANGELOG.md ${{ github.ref_name }} body.md - name: Publish package to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} - name: Create GitHub release uses: ncipollo/release-action@v1 with: artifacts: "dist/*" bodyFile: "body.md" draft: true token: ${{ secrets.GITHUB_TOKEN }} simple-pid-2.0.1/.github/workflows/run-examples.yml000066400000000000000000000017311464720621200223230ustar00rootroot00000000000000name: examples on: # push: # branches: [ master ] # pull_request: # branches: [ master ] workflow_dispatch: jobs: build: name: Run examples runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[examples] - name: Run examples run: | cd examples/water_boiler/ NO_DISPLAY=1 python water_boiler.py - name: Upload resulting figures uses: actions/upload-artifact@v4 with: name: result-py${{ matrix.python-version }}.png path: examples/water_boiler/result-py${{ matrix.python-version }}.png simple-pid-2.0.1/.github/workflows/run-tests.yml000066400000000000000000000021151464720621200216440ustar00rootroot00000000000000name: tests on: push: branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: jobs: build: name: Run tests runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.10"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 mypy pytest black colorama if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Run tests with pytest run: | python -m pytest -v - name: Lint with flake8 run: | python -m flake8 . --count --statistics - name: Run mypy run: | python -m mypy --strict simple_pid - name: Run black run: | python -m black -l 100 -S --check --diff --color --exclude "docs/" . simple-pid-2.0.1/.gitignore000066400000000000000000000002141464720621200155460ustar00rootroot00000000000000venv/ docs/build __pycache__/ *.egg-info/ .eggs/ dist/ build/ .pytest_cache/ .tox/ *.pyc .idea/ .vscode/ .envrc build.sh total_downloads.py simple-pid-2.0.1/.readthedocs.yml000066400000000000000000000007721464720621200166550ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3.11" # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py # Optionally declare the Python requirements required to build your docs python: install: - requirements: docs/requirements.txt simple-pid-2.0.1/CHANGELOG.md000066400000000000000000000105601464720621200153740ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.0.1] - 2024-07-21 ### Fixed - Fix issue where the last error was not reset when calling reset() ## [2.0.0] - 2023-04-28 ### Added - Ability to override the time function by setting PID.time_fn to whichever function to use - Black is now run in CI to detect formatting problems - Project is now defined by a pyproject.toml file instead of the old setup.py. The setup.cfg file remains for flake8 configuration for now. - Ability to give the PID a starting guess for its output, when you start controlling a system that is already at the setpoint and don't want the PID to start outputting 0 - Option for specifying differential_on_measurement, to choose between calculating the derivative term on the input (default) or on the error (classic PID) ### Changed - Rename the module `PID` to `pid` to avoid the shadowing from the `PID` class - CI migrated from Travis to GitHub Actions - The [documentation](https://simple-pid.readthedocs.io/) has gotten an overhaul and a new look. Much of the detailed documentation in README.md has been moved to a dedicated user guide. ### Fixed - Fix mypy issue by explicitly exporting `PID` - Remove duplicated definition of `output_limits` in type stubs ### Deprecated - Official support for Python 2 is dropped. While the code will likely keep working in Python 2 going forward, it's no longer tested in CI and no guarantees are given. ## [1.0.1] - 2021-04-11 ### Fixed - Added type information for public instance variables to typing stub ## [1.0.0] - 2021-03-20 ### Added - Function to map the error value to a different domain - Typing information through a stub file so that users of the library can use e.g. [mypy](https://github.com/python/mypy) to type check their code - This project now uses the [Black code style](https://github.com/psf/black) - The PID class now has a `__repr__()` method, meaning that objects of this type can be printed directly for use during development - MANIFEST.in file to ensure all necessary files are included in the source distribution ### Fixed - Formatting errors in the documentation due to poorly formatted docstrings ## [0.2.4] - 2019-10-08 ### Added - Added optional argument to manually set dt (useful e.g. when running in a simulation) ## [0.2.3] - 2019-08-26 ### Added - A reset method to reset the internal state of the PID controller ## [0.2.2] - 2019-07-04 ### Changed - Don't limit the proportional term to the output bounds when using `proportional_on_measurement` ## [0.2.1] - 2019-03-01 ### Fixed - `ZeroDivisionError` on systems with limited precision time. ## [0.2.0] - 2019-02-26 ### Added - Allow the proportional term to be monitored properly through the components-property when _proportional on measurement_ is enabled. ### Fixed - Bump in output when re-enabling _auto mode_ after running in _manual mode_. ## [0.1.5] - 2019-01-31 ### Added - The ability to see the contributions of the separate terms in the PID ### Fixed - D term not being divided by delta time, leading to wrong output values ## [0.1.4] - 2018-10-03 ### Fixed - Use monotonic time to prevent errors that may be difficult to diagnose when the system time is modified. Thanks [@deniz195](https://github.com/m-lundberg/simple-pid/issues/1) ### Added - Initial implementation [Unreleased]: https://github.com/m-lundberg/simple-pid/compare/v2.0.1...HEAD [2.0.1]: https://github.com/m-lundberg/simple-pid/compare/v2.0.0...v2.0.1 [2.0.0]: https://github.com/m-lundberg/simple-pid/compare/v1.0.1...v2.0.0 [1.0.1]: https://github.com/m-lundberg/simple-pid/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/m-lundberg/simple-pid/compare/v0.2.4...v1.0.0 [0.2.4]: https://github.com/m-lundberg/simple-pid/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/m-lundberg/simple-pid/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/m-lundberg/simple-pid/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/m-lundberg/simple-pid/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/m-lundberg/simple-pid/compare/v0.1.5...v0.2.0 [0.1.5]: https://github.com/m-lundberg/simple-pid/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/m-lundberg/simple-pid/releases/tag/v0.1.4 simple-pid-2.0.1/LICENSE.md000066400000000000000000000020651464720621200151700ustar00rootroot00000000000000MIT License Copyright (c) 2018-2024 Martin Lundberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. simple-pid-2.0.1/MANIFEST.in000066400000000000000000000004621464720621200153210ustar00rootroot00000000000000include CHANGELOG.md include LICENSE.md include README.md recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst *.md conf.py requirements.txt recursive-include examples *.md *.txt *.py recursive-include tests *.py include simple_pid/*.pyi include simple_pid/py.typed simple-pid-2.0.1/README.md000066400000000000000000000036331464720621200150450ustar00rootroot00000000000000# simple-pid [![Tests](https://github.com/m-lundberg/simple-pid/actions/workflows/run-tests.yml/badge.svg)](https://github.com/m-lundberg/simple-pid/actions?query=workflow%3Atests) [![PyPI](https://img.shields.io/pypi/v/simple-pid.svg)](https://pypi.org/project/simple-pid/) [![Read the Docs](https://img.shields.io/readthedocs/simple-pid.svg)](https://simple-pid.readthedocs.io/) [![License](https://img.shields.io/github/license/m-lundberg/simple-pid.svg)](https://github.com/m-lundberg/simple-pid/blob/master/LICENSE.md) [![Downloads](https://static.pepy.tech/badge/simple-pid/month)](https://pepy.tech/project/simple-pid) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) A simple and easy to use PID controller in Python. If you want a PID controller without external dependencies that just works, this is for you! The PID was designed to be robust with help from [Brett Beauregards guide](http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-introduction/). Usage is very simple: ```python from simple_pid import PID pid = PID(1, 0.1, 0.05, setpoint=1) # Assume we have a system we want to control in controlled_system v = controlled_system.update(0) while True: # Compute new output from the PID according to the systems current value control = pid(v) # Feed the PID output to the system and get its current value v = controlled_system.update(control) ``` ## Installation To install, run: ``` python -m pip install simple-pid ``` ## Documentation Documentation, including a user guide and complete API reference, can be found [here](https://simple-pid.readthedocs.io/). ## Tests This project has a test suite using [`pytest`](https://docs.pytest.org/). To run the tests, install `pytest` and run: ``` pytest -v ``` ## License Licensed under the [MIT License](https://github.com/m-lundberg/simple-pid/blob/master/LICENSE.md). simple-pid-2.0.1/docs/000077500000000000000000000000001464720621200145115ustar00rootroot00000000000000simple-pid-2.0.1/docs/requirements.txt000066400000000000000000000001121464720621200177670ustar00rootroot00000000000000furo==2023.3.27 myst-parser==1.0.0 sphinx==6.2.1 sphinx-copybutton==0.5.2 simple-pid-2.0.1/docs/source/000077500000000000000000000000001464720621200160115ustar00rootroot00000000000000simple-pid-2.0.1/docs/source/conf.py000066400000000000000000000034101464720621200173060ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- import os import sys sys.path.insert(0, os.path.abspath('../../')) # -- Project information ----------------------------------------------------- project = 'simple-pid' copyright = '2018-2023, Martin Lundberg' author = 'Martin Lundberg' # Extract version from pyproject.toml with open('../../pyproject.toml', 'r') as f: for line in f: if line.startswith('version'): release = line.split('"')[1] version = release # -- General configuration --------------------------------------------------- extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx_copybutton', 'myst_parser', ] templates_path = ['_templates'] root_doc = 'index' exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_dark_style = 'nord' # -- Options for HTML output ------------------------------------------------- html_theme = 'furo' html_static_path = ['_static'] html_title = f'{project} {release}' # -- Extension configuration ------------------------------------------------- # autoclass_content = 'both' autodoc_class_signature = 'separated' autodoc_member_order = 'bysource' autodoc_typehints = 'description' autodoc_typehints_description_target = 'documented' def autodoc_skip_member(app, what, name, obj, skip, options): # Include __call__ in docs if name in ['__call__']: return False return skip def setup(app): app.connect('autodoc-skip-member', autodoc_skip_member) simple-pid-2.0.1/docs/source/index.md000066400000000000000000000020071464720621200174410ustar00rootroot00000000000000 (welcome)= # simple-pid A simple and easy to use PID controller in Python. If you want a PID controller without external dependencies that just works, this is for you! The PID was designed to be robust with help from [Brett Beauregards guide](http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-introduction/). Usage is very simple: ```python from simple_pid import PID pid = PID(1, 0.1, 0.05, setpoint=1) # Assume we have a system we want to control in controlled_system v = controlled_system.update(0) while True: # Compute new output from the PID according to the systems current value control = pid(v) # Feed the PID output to the system and get its current value v = controlled_system.update(control) ``` Keep reading in the {ref}`user_guide`. ```{toctree} --- maxdepth: 2 hidden: caption: Contents --- self user_guide reference ``` ```{toctree} --- hidden: caption: Project links --- GitHub PyPI ``` simple-pid-2.0.1/docs/source/reference.rst000066400000000000000000000004031464720621200204760ustar00rootroot00000000000000 API reference ============= This library consists of only one class, ``PID``, which you make an instance of and use. It's full API documentation is given below. simple_pid.PID -------------- .. automodule:: simple_pid.pid :members: :undoc-members: simple-pid-2.0.1/docs/source/user_guide.md000066400000000000000000000122661464720621200204750ustar00rootroot00000000000000 (user_guide)= # User guide ## Installation To install, run: ```shell python -m pip install simple-pid ``` ## The basics We already saw a minimal example in {ref}`welcome`: ```python from simple_pid import PID pid = PID(1, 0.1, 0.05, setpoint=1) # Assume we have a system we want to control in controlled_system v = controlled_system.update(0) while True: # Compute new output from the PID according to the systems current value control = pid(v) # Feed the PID output to the system and get its current value v = controlled_system.update(control) ``` This shows the basic structure of a PID loop. You construct a PID object and then in each loop iteration you feed it the current value of whatever system you want to control. The PID takes the error from the setpoint you want to achieve and outputs the value to feed back into the controlled system. The `PID` class implements `__call__()`, which means that to compute a new output value, you simply call the object like this: ```python output = pid(current_value) ``` The PID works best when it is updated at regular intervals. To achieve this, set `sample_time` to the amount of time there should be between each update and then call the PID every time in the program loop. A new output will only be calculated when `sample_time` seconds has passed: ```python pid.sample_time = 0.01 # Update every 0.01 seconds while True: output = pid(current_value) ``` To set the setpoint, ie. the value that the PID is trying to achieve, simply set it like this: ```python pid.setpoint = 10 ``` The tunings can be changed any time when the PID is running. They can either be set individually or all at once: ```python pid.Ki = 1.0 pid.tunings = (1.0, 0.2, 0.4) ``` In order to get output values in a certain range, and also to avoid [integral windup](https://en.wikipedia.org/wiki/Integral_windup) (since the integral term will never be allowed to grow outside of these limits), the output can be limited to a range: ```python pid.output_limits = (0, 10) # Output value will be between 0 and 10 pid.output_limits = (0, None) # Output will always be above 0, but with no upper bound ``` ### Reverse mode To use the PID in [reverse mode](http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-direction/), meaning that an increase in the input leads to a decrease in the output (like when cooling for example), you can set the tunings to negative values: ```python pid.tunings = (-1.0, -0.1, 0) ``` Note that all the tunings should have the same sign. ## Other features ### Auto mode To disable the PID so that no new values are computed, set auto mode to `False`: ```python pid.auto_mode = False # No new values will be computed when pid is called pid.auto_mode = True # pid is enabled again ``` When disabling the PID and controlling a system manually, it might be useful to tell the PID controller where to start from when giving back control to it. This can be done by enabling auto mode like this: ```python pid.set_auto_mode(True, last_output=8.0) ``` This will set the I-term to the value given to `last_output`, meaning that if the system that is being controlled was stable at that output value the PID will keep the system stable if started from that point, without any big bumps in the output when turning the PID back on. ### Observing separate components When tuning the PID, it can be useful to see how each of the components contribute to the output. They can be seen like this: ```python p, i, d = pid.components # The separate terms are now in p, i, d ``` ### Proportional on measurement To eliminate overshoot in certain types of systems, you can calculate the [proportional term directly on the measurement](http://brettbeauregard.com/blog/2017/06/introducing-proportional-on-measurement/) instead of the error. This can be enabled like this: ```python pid.proportional_on_measurement = True ``` ### Differential on measurement By default the [differential term is calculated on the measurement](http://brettbeauregard.com/blog/2011/04/improving-the-beginner%e2%80%99s-pid-derivative-kick/) instead of the error. This can be disabled like this: ```python pid.differential_on_measurement = False ``` ### Error mapping To transform the error value to another domain before doing any computations on it, you can supply an `error_map` callback function to the PID. The callback function should take one argument which is the error from the setpoint. This can be used e.g. to get a degree value error in a yaw angle control with values between `[-pi, pi)`: ```python import math def pi_clip(angle): if angle > 0: if angle > math.pi: return angle - 2*math.pi else: if angle < -math.pi: return angle + 2*math.pi return angle pid.error_map = pi_clip ``` ### Overriding time function By default, the PID uses `time.monotonic()` (or if not available, `time.time()` as fallback) to get the current time on each invocation. The time function can be overridden by setting `PID.time_fn` to whichever function you want to use. For example, to use the [MicroPython `time.ticks_us()`](https://docs.micropython.org/en/latest/library/time.html#time.ticks_us): ```python import time pid.time_fn = time.ticks_us ``` simple-pid-2.0.1/examples/000077500000000000000000000000001464720621200153775ustar00rootroot00000000000000simple-pid-2.0.1/examples/water_boiler/000077500000000000000000000000001464720621200200555ustar00rootroot00000000000000simple-pid-2.0.1/examples/water_boiler/README.md000066400000000000000000000027111464720621200213350ustar00rootroot00000000000000# Water Boiler Example Simple simulation of a water boiler which can heat up water and where the heat dissipates slowly over time. Running the example will run the water boiler simulation for 10 seconds and use the PID controller to make the boiler reach a setpoint temperature. The results will also be plotted using [Matplotlib](https://matplotlib.org). ## Installation It's recommended to install the dependencies (numpy and matplotlib, in addition to the simple-pid library itself) in a virtual environment. ```bash # Linux: python -m venv venv . venv/bin/activate # Windows: python -m venv venv venv/Scripts/activate ``` Then install the example dependencies: ```bash python -m pip install ../..[examples] ``` ## Usage ```bash # Activate the virtual environment if you use one: . venv/bin/activate # Run the example: python water_boiler.py # Once you're done deactivate the virtual environment if you use one: deactivate ``` ## Troubleshooting ### Ubuntu Depending on your environment, you might have to [install a some system dependencies for Matplotlib](https://stackoverflow.com/a/56673945/3767264) to display the graph. Typically, the sign of that is usually one the following errors: - `UserWarning: Matplotlib is currently using agg, which is a non-GUI backend, so cannot show the figure.` - `AttributeError: module 'cairo' has no attribute 'version_info'` (if the system dependencies are already available but not the corresponding Python dependencies) simple-pid-2.0.1/examples/water_boiler/water_boiler.py000077500000000000000000000032241464720621200231110ustar00rootroot00000000000000#!/usr/bin/env python import os import sys import time import matplotlib.pyplot as plt from simple_pid import PID class WaterBoiler: """ Simple simulation of a water boiler which can heat up water and where the heat dissipates slowly over time """ def __init__(self): self.water_temp = 20 def update(self, boiler_power, dt): if boiler_power > 0: # Boiler can only produce heat, not cold self.water_temp += 1 * boiler_power * dt # Some heat dissipation self.water_temp -= 0.02 * dt return self.water_temp if __name__ == '__main__': boiler = WaterBoiler() water_temp = boiler.water_temp pid = PID(5, 0.01, 0.1, setpoint=water_temp) pid.output_limits = (0, 100) start_time = time.time() last_time = start_time # Keep track of values for plotting setpoint, y, x = [], [], [] while time.time() - start_time < 10: current_time = time.time() dt = current_time - last_time power = pid(water_temp) water_temp = boiler.update(power, dt) x += [current_time - start_time] y += [water_temp] setpoint += [pid.setpoint] if current_time - start_time > 1: pid.setpoint = 100 last_time = current_time plt.plot(x, y, label='measured') plt.plot(x, setpoint, label='target') plt.xlabel('time') plt.ylabel('temperature') plt.legend() if os.getenv('NO_DISPLAY'): # If run in CI the plot is saved to file instead of shown to the user plt.savefig(f"result-py{'.'.join([str(x) for x in sys.version_info[:2]])}.png") else: plt.show() simple-pid-2.0.1/pyproject.toml000066400000000000000000000020471464720621200165000ustar00rootroot00000000000000[project] name = "simple-pid" version = "2.0.1" authors = [ { name="Martin Lundberg" }, ] description = "A simple, easy to use PID controller" readme = "README.md" requires-python = ">=3.6" keywords = ["pid", "controller", "control"] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] [project.urls] "Homepage" = "https://github.com/m-lundberg/simple-pid" "Documentation" = "https://simple-pid.readthedocs.io/" [project.optional-dependencies] test = ["pytest"] doc = [ "furo==2023.3.27", "myst-parser==1.0.0", "sphinx==6.2.1", "sphinx-copybutton==0.5.2", ] examples = ["numpy", "matplotlib"] [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["simple_pid"] exclude = ["tests"] [tool.black] line-length = 100 skip-string-normalization = true target-version = ["py39", "py38", "py37", "py36"] extend-exclude = 'docs/' simple-pid-2.0.1/setup.cfg000066400000000000000000000001101464720621200153720ustar00rootroot00000000000000[flake8] exclude=venv/,docs/,simple_pid/__init__.py max-line-length=100 simple-pid-2.0.1/simple_pid/000077500000000000000000000000001464720621200157065ustar00rootroot00000000000000simple-pid-2.0.1/simple_pid/__init__.py000066400000000000000000000000461464720621200200170ustar00rootroot00000000000000from simple_pid.pid import PID as PID simple-pid-2.0.1/simple_pid/pid.py000066400000000000000000000251761464720621200170470ustar00rootroot00000000000000def _clamp(value, limits): lower, upper = limits if value is None: return None elif (upper is not None) and (value > upper): return upper elif (lower is not None) and (value < lower): return lower return value class PID(object): """A simple PID controller.""" def __init__( self, Kp=1.0, Ki=0.0, Kd=0.0, setpoint=0, sample_time=0.01, output_limits=(None, None), auto_mode=True, proportional_on_measurement=False, differential_on_measurement=True, error_map=None, time_fn=None, starting_output=0.0, ): """ Initialize a new PID controller. :param Kp: The value for the proportional gain Kp :param Ki: The value for the integral gain Ki :param Kd: The value for the derivative gain Kd :param setpoint: The initial setpoint that the PID will try to achieve :param sample_time: The time in seconds which the controller should wait before generating a new output value. The PID works best when it is constantly called (eg. during a loop), but with a sample time set so that the time difference between each update is (close to) constant. If set to None, the PID will compute a new output value every time it is called. :param output_limits: The initial output limits to use, given as an iterable with 2 elements, for example: (lower, upper). The output will never go below the lower limit or above the upper limit. Either of the limits can also be set to None to have no limit in that direction. Setting output limits also avoids integral windup, since the integral term will never be allowed to grow outside of the limits. :param auto_mode: Whether the controller should be enabled (auto mode) or not (manual mode) :param proportional_on_measurement: Whether the proportional term should be calculated on the input directly rather than on the error (which is the traditional way). Using proportional-on-measurement avoids overshoot for some types of systems. :param differential_on_measurement: Whether the differential term should be calculated on the input directly rather than on the error (which is the traditional way). :param error_map: Function to transform the error value in another constrained value. :param time_fn: The function to use for getting the current time, or None to use the default. This should be a function taking no arguments and returning a number representing the current time. The default is to use time.monotonic() if available, otherwise time.time(). :param starting_output: The starting point for the PID's output. If you start controlling a system that is already at the setpoint, you can set this to your best guess at what output the PID should give when first calling it to avoid the PID outputting zero and moving the system away from the setpoint. """ self.Kp, self.Ki, self.Kd = Kp, Ki, Kd self.setpoint = setpoint self.sample_time = sample_time self._min_output, self._max_output = None, None self._auto_mode = auto_mode self.proportional_on_measurement = proportional_on_measurement self.differential_on_measurement = differential_on_measurement self.error_map = error_map self._proportional = 0 self._integral = 0 self._derivative = 0 self._last_time = None self._last_output = None self._last_error = None self._last_input = None if time_fn is not None: # Use the user supplied time function self.time_fn = time_fn else: import time try: # Get monotonic time to ensure that time deltas are always positive self.time_fn = time.monotonic except AttributeError: # time.monotonic() not available (using python < 3.3), fallback to time.time() self.time_fn = time.time self.output_limits = output_limits self.reset() # Set initial state of the controller self._integral = _clamp(starting_output, output_limits) def __call__(self, input_, dt=None): """ Update the PID controller. Call the PID controller with *input_* and calculate and return a control output if sample_time seconds has passed since the last update. If no new output is calculated, return the previous output instead (or None if no value has been calculated yet). :param dt: If set, uses this value for timestep instead of real time. This can be used in simulations when simulation time is different from real time. """ if not self.auto_mode: return self._last_output now = self.time_fn() if dt is None: dt = now - self._last_time if (now - self._last_time) else 1e-16 elif dt <= 0: raise ValueError('dt has negative value {}, must be positive'.format(dt)) if self.sample_time is not None and dt < self.sample_time and self._last_output is not None: # Only update every sample_time seconds return self._last_output # Compute error terms error = self.setpoint - input_ d_input = input_ - (self._last_input if (self._last_input is not None) else input_) d_error = error - (self._last_error if (self._last_error is not None) else error) # Check if must map the error if self.error_map is not None: error = self.error_map(error) # Compute the proportional term if not self.proportional_on_measurement: # Regular proportional-on-error, simply set the proportional term self._proportional = self.Kp * error else: # Add the proportional error on measurement to error_sum self._proportional -= self.Kp * d_input # Compute integral and derivative terms self._integral += self.Ki * error * dt self._integral = _clamp(self._integral, self.output_limits) # Avoid integral windup if self.differential_on_measurement: self._derivative = -self.Kd * d_input / dt else: self._derivative = self.Kd * d_error / dt # Compute final output output = self._proportional + self._integral + self._derivative output = _clamp(output, self.output_limits) # Keep track of state self._last_output = output self._last_input = input_ self._last_error = error self._last_time = now return output def __repr__(self): return ( '{self.__class__.__name__}(' 'Kp={self.Kp!r}, Ki={self.Ki!r}, Kd={self.Kd!r}, ' 'setpoint={self.setpoint!r}, sample_time={self.sample_time!r}, ' 'output_limits={self.output_limits!r}, auto_mode={self.auto_mode!r}, ' 'proportional_on_measurement={self.proportional_on_measurement!r}, ' 'differential_on_measurement={self.differential_on_measurement!r}, ' 'error_map={self.error_map!r}' ')' ).format(self=self) @property def components(self): """ The P-, I- and D-terms from the last computation as separate components as a tuple. Useful for visualizing what the controller is doing or when tuning hard-to-tune systems. """ return self._proportional, self._integral, self._derivative @property def tunings(self): """The tunings used by the controller as a tuple: (Kp, Ki, Kd).""" return self.Kp, self.Ki, self.Kd @tunings.setter def tunings(self, tunings): """Set the PID tunings.""" self.Kp, self.Ki, self.Kd = tunings @property def auto_mode(self): """Whether the controller is currently enabled (in auto mode) or not.""" return self._auto_mode @auto_mode.setter def auto_mode(self, enabled): """Enable or disable the PID controller.""" self.set_auto_mode(enabled) def set_auto_mode(self, enabled, last_output=None): """ Enable or disable the PID controller, optionally setting the last output value. This is useful if some system has been manually controlled and if the PID should take over. In that case, disable the PID by setting auto mode to False and later when the PID should be turned back on, pass the last output variable (the control variable) and it will be set as the starting I-term when the PID is set to auto mode. :param enabled: Whether auto mode should be enabled, True or False :param last_output: The last output, or the control variable, that the PID should start from when going from manual mode to auto mode. Has no effect if the PID is already in auto mode. """ if enabled and not self._auto_mode: # Switching from manual mode to auto, reset self.reset() self._integral = last_output if (last_output is not None) else 0 self._integral = _clamp(self._integral, self.output_limits) self._auto_mode = enabled @property def output_limits(self): """ The current output limits as a 2-tuple: (lower, upper). See also the *output_limits* parameter in :meth:`PID.__init__`. """ return self._min_output, self._max_output @output_limits.setter def output_limits(self, limits): """Set the output limits.""" if limits is None: self._min_output, self._max_output = None, None return min_output, max_output = limits if (None not in limits) and (max_output < min_output): raise ValueError('lower limit must be less than upper limit') self._min_output = min_output self._max_output = max_output self._integral = _clamp(self._integral, self.output_limits) self._last_output = _clamp(self._last_output, self.output_limits) def reset(self): """ Reset the PID controller internals. This sets each term to 0 as well as clearing the integral, the last output and the last input (derivative calculation). """ self._proportional = 0 self._integral = 0 self._derivative = 0 self._integral = _clamp(self._integral, self.output_limits) self._last_time = self.time_fn() self._last_output = None self._last_input = None self._last_error = None simple-pid-2.0.1/simple_pid/pid.pyi000066400000000000000000000033251464720621200172100ustar00rootroot00000000000000from typing import Callable, Optional, Tuple _Limits = Tuple[Optional[float], Optional[float]] _Components = Tuple[float, float, float] _Tunings = Tuple[float, float, float] def _clamp(value: Optional[float], limits: _Limits) -> Optional[float]: ... class PID(object): Kp: float Ki: float Kd: float setpoint: float sample_time: Optional[float] proportional_on_measurement: bool differential_on_measurement: bool error_map: Optional[Callable[[float], float]] time_fn: Callable[[], float] def __init__( self, Kp: float = ..., Ki: float = ..., Kd: float = ..., setpoint: float = ..., sample_time: Optional[float] = ..., output_limits: _Limits = ..., auto_mode: bool = ..., proportional_on_measurement: bool = ..., differential_on_measurement: bool = ..., error_map: Optional[Callable[[float], float]] = ..., time_fn: Optional[Callable[[], float]] = ..., starting_output: float = ..., ) -> None: ... def __call__(self, input_: float, dt: Optional[float] = ...) -> Optional[float]: ... def __repr__(self) -> str: ... @property def components(self) -> _Components: ... @property def tunings(self) -> _Tunings: ... @tunings.setter def tunings(self, tunings: _Tunings) -> None: ... @property def auto_mode(self) -> bool: ... @auto_mode.setter def auto_mode(self, enabled: bool) -> None: ... def set_auto_mode(self, enabled: bool, last_output: Optional[float] = ...) -> None: ... @property def output_limits(self) -> _Limits: ... @output_limits.setter def output_limits(self, limits: _Limits) -> None: ... def reset(self) -> None: ... simple-pid-2.0.1/simple_pid/py.typed000066400000000000000000000000001464720621200173730ustar00rootroot00000000000000simple-pid-2.0.1/tests/000077500000000000000000000000001464720621200147235ustar00rootroot00000000000000simple-pid-2.0.1/tests/__init__.py000066400000000000000000000000001464720621200170220ustar00rootroot00000000000000simple-pid-2.0.1/tests/test_pid.py000066400000000000000000000161261464720621200171160ustar00rootroot00000000000000import sys import time import pytest from simple_pid import PID def test_zero(): pid = PID(1, 1, 1, setpoint=0) assert pid(0) == 0 def test_P(): pid = PID(1, 0, 0, setpoint=10, sample_time=None) assert pid(0) == 10 assert pid(5) == 5 assert pid(-5) == 15 def test_P_negative_setpoint(): pid = PID(1, 0, 0, setpoint=-10, sample_time=None) assert pid(0) == -10 assert pid(5) == -15 assert pid(-5) == -5 assert pid(-15) == 5 def test_I(): pid = PID(0, 10, 0, setpoint=10, sample_time=0.1) time.sleep(0.1) assert round(pid(0)) == 10.0 # Make sure we are close to expected value time.sleep(0.1) assert round(pid(0)) == 20.0 def test_I_negative_setpoint(): pid = PID(0, 10, 0, setpoint=-10, sample_time=0.1) time.sleep(0.1) assert round(pid(0)) == -10.0 time.sleep(0.1) assert round(pid(0)) == -20.0 def test_D(): pid = PID(0, 0, 0.1, setpoint=10, sample_time=0.1) # Should not compute derivative when there is no previous input (don't assume 0 as first input) assert pid(0) == 0 time.sleep(0.1) # Derivative is 0 when input is the same assert pid(0) == 0 assert pid(0) == 0 time.sleep(0.1) assert round(pid(5)) == -5 time.sleep(0.1) assert round(pid(15)) == -10 def test_D_negative_setpoint(): pid = PID(0, 0, 0.1, setpoint=-10, sample_time=0.1) time.sleep(0.1) # Should not compute derivative when there is no previous input (don't assume 0 as first input) assert pid(0) == 0 time.sleep(0.1) # Derivative is 0 when input is the same assert pid(0) == 0 assert pid(0) == 0 time.sleep(0.1) assert round(pid(5)) == -5 time.sleep(0.1) assert round(pid(-5)) == 10 time.sleep(0.1) assert round(pid(-15)) == 10 def test_desired_state(): pid = PID(10, 5, 2, setpoint=10, sample_time=None) # Should not make any adjustment when setpoint is achieved assert pid(10) == 0 def test_output_limits(): pid = PID(100, 20, 40, setpoint=10, output_limits=(0, 100), sample_time=None) time.sleep(0.1) assert 0 <= pid(0) <= 100 time.sleep(0.1) assert 0 <= pid(-100) <= 100 def test_sample_time(): pid = PID(setpoint=10, sample_time=10) control = pid(0) # Last value should be returned again assert pid(100) == control def test_time_fn(): pid = PID() # Default time function should be time.monotonic, or time.time in older versions of Python if sys.version_info < (3, 3): assert pid.time_fn == time.time else: assert pid.time_fn == time.monotonic i = 0 def time_function(): nonlocal i i += 1 return i pid.time_fn = time_function for j in range(1, 5): # Call pid a few times and verify that the time function above was used pid(0) assert pid._last_time == j def test_time_fn_notime(): # Deliberately prevent the time module from being imported import sys sys.modules['time'] = None with pytest.raises(ModuleNotFoundError): # Must specify a time_fn if time is not available _ = PID() # We can still create a PID if we specify our own time_fn _ = PID(time_fn=lambda: 0) # Restore time module so the following tests can use it sys.modules['time'] = time def test_starting_output(): # If the PID is started with a system already at the setpoint, we can give it our best guess # for which output it should start at pid = PID(1, 0, 0, setpoint=10, starting_output=25) assert pid(10) == 25 def test_auto_mode(): pid = PID(1, 0, 0, setpoint=10, sample_time=None) # Ensure updates happen by default assert pid(0) == 10 assert pid(5) == 5 # Ensure no new updates happen when auto mode is off pid.auto_mode = False assert pid(1) == 5 assert pid(7) == 5 # Should reset when reactivating pid.auto_mode = True assert pid._last_input is None assert pid._integral == 0 assert pid(8) == 2 # Last update time should be reset to avoid huge dt pid.auto_mode = False time.sleep(1) pid.auto_mode = True assert pid.time_fn() - pid._last_time < 0.01 # Check that setting last_output works pid.auto_mode = False pid.set_auto_mode(True, last_output=10) assert pid._integral == 10 def test_separate_components(): pid = PID(1, 0, 1, setpoint=10, sample_time=0.1) assert pid(0) == 10 assert pid.components == (10, 0, 0) time.sleep(0.1) assert round(pid(5)) == -45 assert tuple(round(term) for term in pid.components) == (5, 0, -50) def test_clamp(): from simple_pid.pid import _clamp assert _clamp(None, (None, None)) is None assert _clamp(None, (-10, 10)) is None # No limits assert _clamp(0, (None, None)) == 0 assert _clamp(100, (None, None)) == 100 assert _clamp(-100, (None, None)) == -100 # Only lower limit assert _clamp(0, (0, None)) == 0 assert _clamp(100, (0, None)) == 100 assert _clamp(-100, (0, None)) == 0 # Only upper limit assert _clamp(0, (None, 0)) == 0 assert _clamp(100, (None, 0)) == 0 assert _clamp(-100, (None, 0)) == -100 # Both limits assert _clamp(0, (-10, 10)) == 0 assert _clamp(-10, (-10, 10)) == -10 assert _clamp(10, (-10, 10)) == 10 assert _clamp(-100, (-10, 10)) == -10 assert _clamp(100, (-10, 10)) == 10 def test_repr(): pid = PID(1, 2, 3, setpoint=10) new_pid = eval(repr(pid)) assert new_pid.Kp == 1 assert new_pid.Ki == 2 assert new_pid.Kd == 3 assert new_pid.setpoint == 10 def test_converge_system(): pid = PID(1, 0.8, 0.04, setpoint=5, output_limits=(-5, 5)) pv = 0 # Process variable def update_system(c, dt): # Calculate a simple system model return pv + c * dt - 1 * dt start_time = time.time() last_time = start_time while time.time() - start_time < 120: c = pid(pv) pv = update_system(c, time.time() - last_time) last_time = time.time() # Check if system has converged assert abs(pv - 5) < 0.1 def test_converge_diff_on_error(): pid = PID(1, 0.8, 0.04, setpoint=5, output_limits=(-5, 5), differential_on_measurement=False) pv = 0 # Process variable def update_system(c, dt): # Calculate a simple system model return pv + c * dt - 1 * dt start_time = time.time() last_time = start_time while time.time() - start_time < 12: c = pid(pv) pv = update_system(c, time.time() - last_time) last_time = time.time() # Check if system has converged assert abs(pv - 5) < 0.1 def test_error_map(): import math def pi_clip(angle): """Transform the angle value to a [-pi, pi) range.""" if angle > 0: if angle > math.pi: return angle - 2 * math.pi else: if angle < -math.pi: return angle + 2 * math.pi return angle sp = 0.0 # Setpoint pv = 5.0 # Process variable pid = PID(1, 0, 0, setpoint=0.0, sample_time=0.1, error_map=pi_clip) # Check if error value is mapped by the function assert pid(pv) == pi_clip(sp - pv)