Add Projects section with inverted pendulum and real-time interface pages

This commit is contained in:
tdehaeze committed 2026-10-17 21:49:47 +02:00
1 parent 0097be4114
commit 0699938cd5
17 files changed
+357

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
title: "Projects"
---
Here are personal and professional projects that are all open-source / open hardware.
Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+109
View File
@@ -0,0 +1,109 @@
---
title: "Inverted Pendulum on a Recycled 3D Printer"
summary: "A pendulum balanced upright on a motorised cart, built from the axis of an old 3D printer and controlled by a Teensy 4.1 at 1 kHz."
tags: ["Hardware", "Software", "Control"]
status: "Completed"
date: 2026-03-22
repo: "https://github.com/tdehaeze/inverted-pendulum"
cover: "cover.jpg"
---
## Overview
This project is a classic control demonstrator: a rigid pendulum is mounted on a cart and kept upright by moving the cart back and forth.
The mechanics come from a recycled 3D printer (Alfawise U20): one of its linear axes, with the stepper motor and belt, carries the pendulum pivot.
A quadrature encoder measures the pendulum angle, and a Teensy 4.1 microcontroller runs the feedback loop at 1 kHz.
A small PyQt5 application on the computer is used to select the controller, change the gains and setpoints, and record data.
{{< figure src="cover.jpg" alt="Photo of the inverted pendulum setup with its main parts labelled" caption="The setup: cart on a belt-driven rail, stepper motor, pendulum encoder and the electronics (labels in French)." >}}
## Hardware
| Component | Details |
|-----------|---------|
| Microcontroller | Teensy 4.1 |
| Stepper motor | DRV8825 driver, 32 microsteps, 5 µm per step |
| Pendulum encoder | Quadrature, 4096 steps per revolution (0.088° per step) |
| Cart travel | ±200 mm hard stop, ±160 mm soft limit |
| Control rate | 1 kHz, driven by a hardware timer (`IntervalTimer`) |
The cart input is an acceleration command.
It is integrated to a velocity inside the 1 kHz interrupt, and converted into step pulses for the motor driver.
The pendulum angular velocity is estimated by finite difference of the encoder reading.
A soft limit blocks accelerations towards the end of the rail, and a hard stop at ±200 mm cuts the motor.
## Software
The repository has three parts:
- **`teensy/`**: firmware (PlatformIO / Arduino framework). Runs the control loop, drives the motor, reads the encoder and streams telemetry over USB serial.
- **`gui/`**: PyQt5 and pyqtgraph application. Sends text commands to the Teensy (`MODE PD`, `SET KP 12`, `FB ON`, ...), plots the telemetry live and saves it to CSV.
- **`model/` and `tools/`**: Python scripts for simulation, controller design, model identification and plotting experiments.
## Model and identification
With φ = θ − π the angle from the upright position and u the cart acceleration, the linearised dynamics around the upright position are:
```
D·φ̈ + c·φ̇ − m·g·l·φ = m·l·u
```
where D is the effective inertia of the pendulum about its pivot.
The state is [x, ẋ, φ, φ̇] and the input is the cart acceleration.
The pendulum length and friction coefficient are identified from a free oscillation: the pendulum hangs down, receives a small push, and the decaying oscillation is recorded for 10 to 50 seconds.
A Nelder-Mead fit (`tools/fit.py`) then adjusts the model to the measurement.
| Parameter | Symbol | Value |
|-----------|--------|-------|
| Pendulum mass | m | 0.1 kg |
| Half length (pivot to centre of mass) | l | 0.326 m |
| Friction coefficient | c | 3.4 × 10⁻⁴ N·m·s/rad |
{{< figure src="identification-fit.png" alt="Measured and fitted free oscillation of the pendulum" caption="Free oscillation of the pendulum: measurement and fitted model (figure labels in French)." >}}
{{< video src="identification.mp4" caption="Free oscillation used for the identification." >}}
## Controllers
All gains are in SI units, and the controller output is the cart acceleration.
- **P**: u = −Kp · φ
- **PD**: u = −Kp · φ − Kd · φ̇
- **LQR**: u = −K · [x, ẋ, φ, φ̇]
The LQR gain vector K is computed in Python (`model/inverted_pendulum.py`, using the `control` library) from weights on the four states and on the input, then entered in the GUI.
### PD control
A PD controller can hold the pendulum angle at zero, but it does not see the cart position.
In the experiment below the angle is regulated while the cart drifts until it reaches the end of the rail and the pendulum falls.
The same controller with other gains acts mostly as a damper: it slows down the pendulum without fully stabilising it.
{{< figure src="pd-simulation.png" alt="Simulated PD response of the pendulum angle" caption="Simulated PD response, from a 5° initial angle (figure labels in French)." >}}
{{< figure src="pd-experiment.png" alt="Measured angle and cart position with a PD controller" caption="Measured PD experiment: the angle is held near zero while the cart drifts to the end stop (figure labels in French)." >}}
{{< video src="pd-control.mp4" caption="PD control: the pendulum is stabilised, the cart slowly drifts." >}}
{{< video src="pd-damping.mp4" caption="PD control used as a damper only: the pendulum dynamics are slowed down." >}}
### LQR control
The LQR feeds back the cart position and velocity as well as the pendulum angle and angular velocity.
This keeps the cart near its setpoint while the pendulum stays upright.
Higher weights on the states give faster corrections, at the cost of more aggressive cart motion.
{{< video src="lqr.mp4" caption="LQR with a higher gain: angle and cart position are controlled together." >}}
{{< video src="lqr-setpoint-step.mp4" caption="LQR following a step in the cart position setpoint while keeping the pendulum upright." >}}
## Typical workflow
1. Flash the firmware with `pio run -e teensy41 -t upload` from `teensy/`.
2. Start the GUI (`python gui/program.py`), enable the motor and zero the cart and encoder.
3. Record a free oscillation (`tools/capture.py`) and fit the model parameters (`tools/fit.py`).
4. Pick a controller in the GUI, enter the gains, bring the pendulum close to upright and switch the feedback on.
5. Save the data to CSV and plot it with `tools/plot_experiment.py`.
The full instructions and serial command list are in the [README](https://github.com/tdehaeze/inverted-pendulum).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,53 @@
---
title: "rtint-gui: Desktop GUI for Real-Time Control"
summary: "A desktop application to acquire signals, identify plant dynamics and design feedback control on a real-time target."
tags: ["Software", "Control"]
status: "Active"
date: 2026-09-01
repo: "https://gitlab.esrf.fr/dehaeze/real-time-interface-gui"
docs: "https://dehaeze.gitlab-pages.esrf.fr/real-time-interface-gui/"
pypi: "https://pypi.org/project/rtint-gui/"
---
<!-- TODO: confirm public URL -->
<!-- TODO: add GUI screenshots (e.g. Signal Analysis, Dynamical Identification, Feedback Control Design) -->
## Overview
`rtint-gui` is a desktop application for working with a real-time target that runs a Resyst server.
It is built on top of [rtint]({{< relref "real-time-interface" >}}), which handles the communication with the target, and follows the usual steps of a control task:
1. **Connect** to a running server.
2. **Acquire** signals from the target.
3. **Analyse** the records as time traces and spectra (PSD / ASD).
4. **Identify** the plant dynamics by measuring frequency responses.
5. **Design** feedback control from the identified dynamics.
Each step is a page of the application.
## Pages
- **Signal Analysis**: choose counters and model signals, set a duration, watch the acquisition live, then compute power spectra with configurable window, overlap and averaging.
- **Dynamical Identification**: excite the plant with a generator and estimate its frequency response (Bode plot, coherence, and an RGA plot for multi-input multi-output systems). Open-loop and closed-loop identification are supported.
- **Transfer Function Analysis**: overlay measured responses with the Bode plots of the filters running on the target, and with their products.
- **Feedback Control Design**: from an identified plant and the filters used as controller, compute characteristic loci, sensitivity and complementary sensitivity, the modulus margin and a Nyquist stability verdict.
- **Filters, motors, EtherCAT, parameters and more**: inspect and edit what the real-time model exposes.
Results are saved as MATLAB `.mat` files, so they can be reopened in MATLAB or Python.
The analysis pages also work offline on previously saved data, without a target.
## Installation
```bash
pipx install rtint-gui # or: uv tool install rtint-gui
rtint-gui
```
Python 3.12 or 3.13 is required.
The interface uses PySide6 and pyqtgraph.
Running `rtint-gui --offline` starts it without a real-time target.
The [documentation](https://dehaeze.gitlab-pages.esrf.fr/real-time-interface-gui/) has a guide for each page and a description of the `.mat` file format.
## Related
- [rtint]({{< relref "real-time-interface" >}}): the Python package this application is built on.
@@ -0,0 +1,65 @@
---
title: "rtint: Python Interface to Real-Time Targets"
summary: "A Python package to control and monitor Simulink models running on a real-time target machine, such as a Speedgoat."
tags: ["Software", "Control"]
status: "Active"
date: 2026-10-01
repo: "https://gitlab.esrf.fr/dehaeze/real-time-interface"
pypi: "https://pypi.org/project/rtint/"
---
<!-- TODO: confirm public URL -->
## Overview
`rtint` is a Python package that talks to Simulink models running on a real-time target machine, through a Resyst server.
It wraps the server's client API so that the parts of a real-time model (motors, filters, feedback loops, signals and so on) can be used as ordinary Python objects from a script or an interactive session.
It is used at the ESRF to commission and tune mechatronic systems whose control runs on a real-time target.
For a graphical front end built on top of it, see [rtint-gui]({{< relref "real-time-interface-gui" >}}).
## What it covers
Each part of the real-time model has its own controller object, available as an attribute of the main `RtintController`:
- **Motors**: start, stop, move and read the state.
- **Regulation**: switch feedback loops on and off.
- **Filters**: read and change filter parameters, for example low-pass, PID and notch filters, and compute their frequency responses.
- **Signals and parameters**: read and write model signals and tunable parameters.
- **Acquisition**: record signals at the model sampling rate.
- **Signal generators**: sine, chirp and other excitation signals, useful for system identification.
- **Triggers, lookup tables, EtherCAT slaves and 2D scans.**
Most objects can print a summary table of their state in the terminal.
## Example
```python
from rtint.rtint_hardware import RtintController
config = {
"server": "my-server-hostname",
"port": 8080,
}
controller = RtintController("my_target", config)
controller._run_model("my_model")
# Access hardware components
controller.motor.start()
controller.regul.on()
controller.filter.low_pass(wn=100, xi=0.707)
```
## Installation
```bash
pip install rtint
```
Python 3.9 to 3.13 is supported.
The package depends on `resyst-client`, `numpy`, `scipy`, `matplotlib`, `gevent`, `treelib` and `tabulate`.
A running Resyst server and a real-time model are needed to do anything useful with it.
## Related
- [rtint-gui]({{< relref "real-time-interface-gui" >}}): desktop application built on this package.
+60
View File
@@ -0,0 +1,60 @@
<style>
.project-filter { display: flex; flex-wrap: wrap; gap: .5em; justify-content: center; margin: 1.5em 0 1em; }
.project-filter button {
font: inherit; font-size: .9em; cursor: pointer; padding: .25em .9em;
border: 1px solid #d5d8e0; border-radius: 999px; background: #fff; color: #34495e;
}
.project-filter button:hover { border-color: hsl(232, 61%, 64%); }
.project-filter button.active { background: hsl(232, 61%, 64%); border-color: hsl(232, 61%, 64%); color: #fff; }
.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5em; margin: 1.5em 0 2em; }
.project-card {
display: flex; flex-direction: column; background: #fff; border: 1px solid #e3e5ea; border-radius: 8px;
overflow: hidden; transition: box-shadow .2s, transform .2s;
}
.project-card:hover { box-shadow: 0 6px 18px rgba(40, 50, 90, .12); transform: translateY(-2px); }
.project-card[hidden] { display: none; }
.project-card-cover { display: block; aspect-ratio: 16 / 10; background: #eef0f5; overflow: hidden; border: 0; }
.project-card-cover:hover { border: 0; }
.project-card-cover img { width: 100%; height: 100%; object-fit: cover; display: block; margin: 0; }
.project-card-cover.placeholder { display: flex; align-items: center; justify-content: center; padding: 0 .5em; text-align: center; color: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 1.8em; font-weight: 600; background: linear-gradient(135deg, hsl(232, 61%, 64%), hsl(232, 45%, 38%)); }
.project-card-cover.placeholder:hover { color: #fff; }
.project-card-body { display: flex; flex-direction: column; gap: .5em; padding: 1em 1.1em 1.1em; flex: 1; }
.project-card-title { margin: 0; font-size: 1.2em; line-height: 1.25; }
.project-card-title a { color: inherit; border: 0; }
.project-card-title a:hover { color: hsl(232, 61%, 64%); border: 0; }
.project-card-summary { margin: 0; color: #4a5568; font-size: .95em; line-height: 1.5; flex: 1; }
.project-card-footer { display: flex; align-items: center; justify-content: space-between; gap: .5em; font-size: .85em; margin-top: .3em; }
.project-card-footer a { color: #c05b4d; white-space: nowrap; }
.project-tags { display: flex; flex-wrap: wrap; gap: .35em; }
.project-tag {
display: inline-block; padding: .05em .65em; font-size: .78em; font-weight: 600; letter-spacing: .02em;
border-radius: 999px; background: hsl(232, 61%, 94%); color: hsl(232, 45%, 40%);
}
.project-status { font-size: .85em; color: #8a8a8a; }
.project-status::before { content: ""; display: inline-block; width: .55em; height: .55em; border-radius: 50%; margin-right: .4em; background: #b0b4c0; }
.project-status[data-status="active"]::before { background: #4caf7d; }
.project-status[data-status="completed"]::before { background: hsl(232, 61%, 64%); }
.project-status[data-status="in progress"]::before { background: #e0a030; }
.project-summary { text-align: center; font-size: 1.1em; color: #4a5568; max-width: 40em; margin: .4em auto 1em; }
.project-header .project-tags { justify-content: center; }
.project-meta { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .6em 1.4em; margin: 1em 0 1.5em; font-size: .95em; color: #6b7280; }
.project-btn {
display: inline-block; padding: .35em 1em; border-radius: 4px; border: 1px solid hsl(232, 61%, 64%);
color: hsl(232, 61%, 64%); font-size: .95em;
}
.project-btn:hover { background: hsl(232, 61%, 64%); color: #fff; border-color: hsl(232, 61%, 64%); }
.project-btn.primary { background: hsl(232, 61%, 64%); color: #fff; }
.project-btn.primary:hover { background: hsl(232, 61%, 54%); }
.project-back { text-align: center; margin-top: 2em; }
.project-content figure, figure.project-video { margin: 1.5em auto; text-align: center; }
.project-content figure img { max-width: 100%; height: auto; }
figure.project-video video { display: block; margin: 0 auto; width: 100%; max-width: 420px; max-height: 75vh; background: #000; border-radius: 6px; }
figure.project-video.wide video { max-width: 100%; }
figure.project-video figcaption, .project-content figcaption { margin-top: .5em; font-size: .9em; color: #6b7280; }
.project-content table { margin: 1em auto; }
@media (max-width: 600px) { .project-grid { grid-template-columns: 1fr; } }
</style>
+31
View File
@@ -0,0 +1,31 @@
{{ define "title" }}{{ .Title }} - {{ .Site.Title }}{{ end }}
{{ define "content" }}
{{ partial "projects/styles.html" . }}
<article class="post">
{{- with .Content }}<div class="post-content">{{ . }}</div>{{ end }}
<div class="project-grid">
{{- range .Pages.ByDate.Reverse }}
{{- $cover := .Resources.GetMatch (.Params.cover | default "cover.*") -}}
<div class="project-card">
{{- if $cover }}
<a class="project-card-cover" href="{{ .RelPermalink }}" tabindex="-1" aria-hidden="true">
<img src="{{ $cover.RelPermalink }}" alt="" loading="lazy">
</a>
{{- else }}
<a class="project-card-cover placeholder" href="{{ .RelPermalink }}" tabindex="-1" aria-hidden="true">{{ index (split .Title ":") 0 }}</a>
{{- end }}
<div class="project-card-body">
<h2 class="project-card-title"><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
{{- with .Params.summary }}<p class="project-card-summary">{{ . }}</p>{{ end }}
<div class="project-card-footer">
{{- with .Params.status }}<span class="project-status" data-status="{{ . | lower }}">{{ . }}</span>{{ else }}<span></span>{{ end }}
{{- with .Params.repo }}<a href="{{ . }}" target="_blank" rel="noopener">Source code ↗</a>{{ end }}
</div>
</div>
</div>
{{- end }}
</div>
</article>
{{ end }}
+22
View File
@@ -0,0 +1,22 @@
{{ define "title" }}{{ .Title }} - {{ .Site.Title }}{{ end }}
{{ define "content" }}
{{ partial "projects/styles.html" . }}
<article class="post project-page">
<header class="post-header project-header">
<h1 class="post-title">{{ .Title }}</h1>
{{- with .Params.summary }}<p class="project-summary">{{ . }}</p>{{ end }}
<div class="project-meta">
{{- with .Params.status }}<span class="project-status" data-status="{{ . | lower }}">{{ . }}</span>{{ end }}
{{- if not .Date.IsZero }}<span>{{ .Date.Format "January 2006" }}</span>{{ end }}
{{- with .Params.repo }}<a class="project-btn primary" href="{{ . }}" target="_blank" rel="noopener">Source code ↗</a>{{ end }}
{{- with .Params.docs }}<a class="project-btn" href="{{ . }}" target="_blank" rel="noopener">Documentation</a>{{ end }}
{{- with .Params.pypi }}<a class="project-btn" href="{{ . }}" target="_blank" rel="noopener">PyPI</a>{{ end }}
</div>
</header>
<div class="post-content project-content">
{{ .Content }}
</div>
<p class="project-back"><a href="{{ .Parent.RelPermalink }}">← All projects</a></p>
</article>
{{ end }}
+12
View File
@@ -0,0 +1,12 @@
{{- $src := .Get "src" | default (.Get 0) -}}
{{- $caption := .Get "caption" -}}
{{- $poster := .Get "poster" -}}
{{- with .Page.Resources.GetMatch $src }}{{ $src = .RelPermalink }}{{ end -}}
{{- with $poster }}{{ with $.Page.Resources.GetMatch . }}{{ $poster = .RelPermalink }}{{ end }}{{ end -}}
<figure class="project-video">
<video controls preload="metadata" playsinline{{ with $poster }} poster="{{ . }}"{{ end }}>
<source src="{{ $src }}" type="video/mp4">
Your browser does not support the video tag. <a href="{{ $src }}">Download the video</a>.
</video>
{{- with $caption }}<figcaption>{{ . | markdownify }}</figcaption>{{ end }}
</figure>