diff --git a/content/projects/_index.md b/content/projects/_index.md new file mode 100644 index 0000000..3599b2e --- /dev/null +++ b/content/projects/_index.md @@ -0,0 +1,5 @@ +--- +title: "Projects" +--- + +Here are personal and professional projects that are all open-source / open hardware. diff --git a/content/projects/inverted-pendulum/cover.jpg b/content/projects/inverted-pendulum/cover.jpg new file mode 100644 index 0000000..69c33e3 Binary files /dev/null and b/content/projects/inverted-pendulum/cover.jpg differ diff --git a/content/projects/inverted-pendulum/identification-fit.png b/content/projects/inverted-pendulum/identification-fit.png new file mode 100644 index 0000000..5c03d8c Binary files /dev/null and b/content/projects/inverted-pendulum/identification-fit.png differ diff --git a/content/projects/inverted-pendulum/identification.mp4 b/content/projects/inverted-pendulum/identification.mp4 new file mode 100644 index 0000000..2938aa1 Binary files /dev/null and b/content/projects/inverted-pendulum/identification.mp4 differ diff --git a/content/projects/inverted-pendulum/index.md b/content/projects/inverted-pendulum/index.md new file mode 100644 index 0000000..daad56a --- /dev/null +++ b/content/projects/inverted-pendulum/index.md @@ -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). diff --git a/content/projects/inverted-pendulum/lqr-setpoint-step.mp4 b/content/projects/inverted-pendulum/lqr-setpoint-step.mp4 new file mode 100644 index 0000000..27e218f Binary files /dev/null and b/content/projects/inverted-pendulum/lqr-setpoint-step.mp4 differ diff --git a/content/projects/inverted-pendulum/lqr.mp4 b/content/projects/inverted-pendulum/lqr.mp4 new file mode 100644 index 0000000..56172f9 Binary files /dev/null and b/content/projects/inverted-pendulum/lqr.mp4 differ diff --git a/content/projects/inverted-pendulum/pd-control.mp4 b/content/projects/inverted-pendulum/pd-control.mp4 new file mode 100644 index 0000000..40757eb Binary files /dev/null and b/content/projects/inverted-pendulum/pd-control.mp4 differ diff --git a/content/projects/inverted-pendulum/pd-damping.mp4 b/content/projects/inverted-pendulum/pd-damping.mp4 new file mode 100644 index 0000000..961f2d9 Binary files /dev/null and b/content/projects/inverted-pendulum/pd-damping.mp4 differ diff --git a/content/projects/inverted-pendulum/pd-experiment.png b/content/projects/inverted-pendulum/pd-experiment.png new file mode 100644 index 0000000..baf5787 Binary files /dev/null and b/content/projects/inverted-pendulum/pd-experiment.png differ diff --git a/content/projects/inverted-pendulum/pd-simulation.png b/content/projects/inverted-pendulum/pd-simulation.png new file mode 100644 index 0000000..2aa07bc Binary files /dev/null and b/content/projects/inverted-pendulum/pd-simulation.png differ diff --git a/content/projects/real-time-interface-gui/index.md b/content/projects/real-time-interface-gui/index.md new file mode 100644 index 0000000..0c2054b --- /dev/null +++ b/content/projects/real-time-interface-gui/index.md @@ -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/" +--- + + + +## 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. diff --git a/content/projects/real-time-interface/index.md b/content/projects/real-time-interface/index.md new file mode 100644 index 0000000..b99bfa3 --- /dev/null +++ b/content/projects/real-time-interface/index.md @@ -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/" +--- + + +## 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. diff --git a/layouts/partials/projects/styles.html b/layouts/partials/projects/styles.html new file mode 100644 index 0000000..02862a7 --- /dev/null +++ b/layouts/partials/projects/styles.html @@ -0,0 +1,60 @@ + diff --git a/layouts/projects/list.html b/layouts/projects/list.html new file mode 100644 index 0000000..a3237b0 --- /dev/null +++ b/layouts/projects/list.html @@ -0,0 +1,31 @@ +{{ define "title" }}{{ .Title }} - {{ .Site.Title }}{{ end }} + +{{ define "content" }} +{{ partial "projects/styles.html" . }} +
+ {{- with .Content }}
{{ . }}
{{ end }} + +
+ {{- range .Pages.ByDate.Reverse }} + {{- $cover := .Resources.GetMatch (.Params.cover | default "cover.*") -}} +
+ {{- if $cover }} + + {{- else }} + + {{- end }} +
+

{{ .Title }}

+ {{- with .Params.summary }}

{{ . }}

{{ end }} + +
+
+ {{- end }} +
+
+{{ end }} diff --git a/layouts/projects/single.html b/layouts/projects/single.html new file mode 100644 index 0000000..f7c9cc9 --- /dev/null +++ b/layouts/projects/single.html @@ -0,0 +1,22 @@ +{{ define "title" }}{{ .Title }} - {{ .Site.Title }}{{ end }} + +{{ define "content" }} +{{ partial "projects/styles.html" . }} +
+
+

{{ .Title }}

+ {{- with .Params.summary }}

{{ . }}

{{ end }} +
+ {{- with .Params.status }}{{ . }}{{ end }} + {{- if not .Date.IsZero }}{{ .Date.Format "January 2006" }}{{ end }} + {{- with .Params.repo }}Source code ↗{{ end }} + {{- with .Params.docs }}Documentation{{ end }} + {{- with .Params.pypi }}PyPI{{ end }} +
+
+
+ {{ .Content }} +
+

← All projects

+
+{{ end }} diff --git a/layouts/shortcodes/video.html b/layouts/shortcodes/video.html new file mode 100644 index 0000000..15869ff --- /dev/null +++ b/layouts/shortcodes/video.html @@ -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 -}} +
+ + {{- with $caption }}
{{ . | markdownify }}
{{ end }} +