Add Projects section with inverted pendulum and real-time interface pages
This commit is contained in:
1 parent
0097be4114
commit
0699938cd5
17 files changed
+357
No files matched your search
@@ -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 |
Binary file not shown.
@@ -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.
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.
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user