Skip to content

Commit ed896f8

Browse files
CI Python 3.13 only; Dockerfile fix and docs; README tests section; PID plot; utils docstrings and imports
Made-with: Cursor
1 parent 83910f3 commit ed896f8

12 files changed

Lines changed: 126 additions & 179 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,21 @@ on:
1111
jobs:
1212
build:
1313
runs-on: ubuntu-latest
14-
15-
strategy:
16-
matrix:
17-
python-version: ["3.9", "3.13"]
1814

1915
steps:
2016
- name: Check out the repository
2117
uses: actions/checkout@v4
22-
23-
- name: Set up Python ${{ matrix.python-version }}
18+
19+
- name: Set up Python 3.13
2420
uses: actions/setup-python@v4
2521
with:
26-
python-version: ${{ matrix.python-version }}
22+
python-version: "3.13"
2723

2824
- name: Install dependencies
2925
run: |
3026
python -m pip install --upgrade pip
3127
pip install -r requirements.txt
32-
28+
3329
- name: Run tests
3430
run: |
3531
python tests/PID.py

Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Image to run the CPU load generator in a container.
2+
# Build: docker build -t cpuloadgen .
3+
# Run: docker run --rm cpuloadgen -c 0 -l 0.2 -d 10
14
FROM python:3.13
25
LABEL maintainer="GaetanoCarlucci <gaetano.carlucci@gmail.com>"
36

@@ -6,5 +9,5 @@ RUN cd / && git clone https://github.com/GaetanoCarlucci/CPULoadGenerator.git /C
69
RUN pip install -r /CPULoadGenerator/requirements.txt
710

811
WORKDIR /CPULoadGenerator
9-
ENTRYPOINT ["./cpu_load_generator.py"]
12+
ENTRYPOINT ["python", "cpu_load_generator.py"]
1013
CMD ["--help"]

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ Or make the script executable and run: `chmod +x cpu_load_generator.py` then `./
7272
5. **Example graph of CPU load (50% target on core 0):**
7373

7474
![Example - 50% load on CPU core 0](https://raw.githubusercontent.com/molguin92/CPULoadGenerator/python3_port_stable/50%25-Target-Load.png)
75+
76+
## Tests
77+
78+
Test and identification scripts live in `tests/` (see `tests/README.md`). Expected output from the PID script after CPU identification:
79+
80+
![Expected output from the PID script after CPU identification](tests/PID_Actuation.png)

cpu_load_generator.py

Lines changed: 54 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44
# Authors: Gaetano Carlucci
55
# Giuseppe Cofano
66
# Python3 port: Manuel Olguín
7+
import argparse
78
import itertools
89
import multiprocessing
910
import os
1011
import signal
11-
from typing import cast, Callable
12-
13-
import click
12+
import sys
1413
import psutil
1514

1615
from utils.ClosedLoopActuator import ClosedLoopActuator, \
@@ -99,71 +98,59 @@ def load_core(target_core, target_load,
9998
control.join()
10099

101100

102-
def __validate_cpu_load(_ctx, _param, value):
103-
for v in value:
101+
def _parse_args():
102+
"""Parse command-line arguments. Exits on validation errors."""
103+
available = _available_cores()
104+
p = argparse.ArgumentParser(
105+
description='Generate a fixed CPU load on one or more cores (PI controller).'
106+
)
107+
p.add_argument(
108+
'--core', '-c', type=int, nargs='*', default=available,
109+
metavar='N',
110+
help=f'CPU core(s) to load (default: all: {available})'
111+
)
112+
p.add_argument(
113+
'--cpu_load', '-l', type=float, nargs='*', default=[0.2],
114+
metavar='L',
115+
help='Target load per core in [0, 1]; one value for all cores (default: 0.2)'
116+
)
117+
p.add_argument(
118+
'--duration', '-d', type=float, default=-1,
119+
help='Duration in seconds; negative = until SIGINT/SIGTERM (default: -1)'
120+
)
121+
p.add_argument(
122+
'--plot', '-p', action='store_true',
123+
help='Plot CPU load and save a PNG (single core, fixed duration only)'
124+
)
125+
p.add_argument(
126+
'--sampling_interval', '-s', type=float, default=0.1,
127+
help='PI controller sampling interval in seconds (default: 0.1)'
128+
)
129+
args = p.parse_args()
130+
131+
core = args.core if args.core else available
132+
cpu_load = args.cpu_load if args.cpu_load else [0.2]
133+
134+
for v in core:
135+
if v not in available:
136+
sys.exit(f'Target core {v} is not in available cores: {available}')
137+
for v in cpu_load:
104138
if not 0. <= v <= 1.:
105-
msg = f'CPU load {v} out of range [0, 1]'
106-
raise click.BadOptionUsage(message=msg, option_name='cpu_load')
107-
return value
108-
109-
110-
def __validate_cpu_core(_ctx, _param, value):
111-
available_cores = _available_cores()
112-
113-
for v in value:
114-
if v not in available_cores:
115-
msg = (f'Target core ({v}) is not one of the available cores: '
116-
f'{available_cores}')
117-
raise click.BadOptionUsage(message=msg, option_name='core')
118-
return value
119-
120-
121-
def __validate_sampling_interval(_ctx, _param, value):
122-
if value < 0:
123-
msg = f'Sampling interval cannot be negative ({value}).'
124-
raise click.BadOptionUsage(message=msg, option_name='sampling_interval')
125-
return value
126-
127-
128-
@click.command()
129-
@click.option('--core', '-c',
130-
type=int, callback=__validate_cpu_core,
131-
required=True, multiple=True,
132-
help='CPU core to artificially load. '
133-
'Can be specified multiple times to load multiple cores, '
134-
'default is all cores.',
135-
default=_available_cores(),
136-
show_default=True)
137-
@click.option('--cpu_load', '-l',
138-
type=float, multiple=True,
139-
help='Target CPU load. If only one value is provided, '
140-
'it is applied to all affected cores, otherwise specifies '
141-
'load per affected core.',
142-
default=[0.2], show_default=True, callback=__validate_cpu_load)
143-
@click.option('--duration', '-d',
144-
type=float, default=-1, show_default=True,
145-
help='Duration in seconds. If omitted or negative, '
146-
'program will run until a SIGINT or SIGTERM is received.')
147-
@click.option('--plot', '-p',
148-
is_flag=True, default=False, show_default=True,
149-
help='Plot the resulting CPU load (and save a PNG at the end). '
150-
'Can only be used with a fixed duration.')
151-
@click.option('--sampling_interval', '-s',
152-
type=float, default=0.1, show_default=True,
153-
help='Sampling interval, in seconds, '
154-
'for the internal PI controller. '
155-
'Changing this value is strongly discouraged!',
156-
callback=__validate_sampling_interval)
157-
158-
def __main(core, cpu_load, duration, plot, sampling_interval):
159-
if plot and duration < 0:
160-
msg = 'Plot option can only be used with a fixed duration.'
161-
raise click.BadOptionUsage(message=msg, option_name='plot')
162-
139+
sys.exit(f'CPU load {v} out of range [0, 1]')
140+
if args.sampling_interval < 0:
141+
sys.exit(f'Sampling interval cannot be negative ({args.sampling_interval})')
142+
if args.plot and args.duration < 0:
143+
sys.exit('Plot option requires a fixed duration (use -d SECONDS).')
163144
if len(cpu_load) > 1 and len(cpu_load) != len(core):
164-
msg = 'Number of cores and loads does not match.'
165-
raise click.BadOptionUsage(message=msg, option_name='cpu_load')
166-
elif len(cpu_load) == 1:
145+
sys.exit('Number of cores and loads must match when specifying multiple loads.')
146+
147+
return core, cpu_load, args.duration, args.plot, args.sampling_interval
148+
149+
150+
def _main() -> None:
151+
"""Entry point: parse CLI, then run load on selected core(s)."""
152+
core, cpu_load, duration, plot, sampling_interval = _parse_args()
153+
if len(cpu_load) == 1:
167154
cpu_load = itertools.repeat(cpu_load[0], len(core))
168155

169156
# filter out repeated core indexes
@@ -195,4 +182,4 @@ def __main(core, cpu_load, duration, plot, sampling_interval):
195182

196183

197184
if __name__ == '__main__':
198-
cast(Callable[[], None], __main)()
185+
_main()

requirements.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
matplotlib
2-
psutil
3-
click
1+
matplotlib>=3.5,<5
2+
psutil>=5.9,<7

tests/PID.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from cpu_load_generator import _available_cores
1616
from utils.Monitor import MonitorThread
1717
from utils.Controller import ControllerThread
18-
from utils.ClosedLoopActuator import ClosedLoopActuator
18+
from utils.ClosedLoopActuator import ClosedLoopActuator, PlottingClosedLoopActuator
1919

2020
if __name__ == "__main__":
2121

@@ -36,8 +36,10 @@
3636

3737
control = ControllerThread(0.1)
3838
monitor = MonitorThread(core, 0.1)
39-
actuator = ClosedLoopActuator(control, monitor, len(cpuSequence) *
40-
stepPeriod, 1)
39+
# Use PlottingClosedLoopActuator to see real-time plot; use ClosedLoopActuator for no plot
40+
actuator_cls = PlottingClosedLoopActuator if dynamics_plot_online else ClosedLoopActuator
41+
actuator = actuator_cls(control, monitor, len(cpuSequence) *
42+
stepPeriod, core)
4143

4244
monitor.start()
4345
control.start()

tests/PID_Actuation.png

39.4 KB
Loading

tests/README.md

Lines changed: 17 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,33 @@
1-
# Review: tests in the `tests/` folder
1+
# Tests
22

3-
## Overview
3+
## What runs in CI
44

5-
| File | Purpose | CI |
6-
|------|--------|-----|
7-
| **PID.py** | PI controller closed-loop test: CPU target sequence, save dynamics, PNG plots | ✅ run |
8-
| **feedForward.py** | Open-loop test: sleep time sequence (CPU model), comparison with monitor | ✅ run |
9-
| **Identification.py** | Identification: for each sleep time estimates mean CPU load, scatter plot sleep vs load | ❌ not in CI |
10-
| **Identification2.py** | Alternative identification: sleep time sequence, dynamics plot and PNG | ❌ not in CI |
5+
On every push and pull request to `master`, the [CI workflow](../.github/workflows/ci.yml) runs:
116

12-
---
7+
- **Python 3.13** on Ubuntu
8+
- After `pip install -r requirements.txt`:
9+
- `python tests/PID.py`
10+
- `python tests/feedForward.py`
1311

14-
## Strengths
12+
If both scripts complete without errors, the build passes. They produce data files and PNGs in the current working directory (repo root when run from CI).
1513

16-
- **Component reuse:** Monitor, Controller, ClosedLoopActuator, OpenLoopActuator used consistently.
17-
- **Reproducibility:** `testing = 1` runs the experiment, `testing = 0` loads data from JSON files (useful to regenerate plots without re-running).
18-
- **CI:** PID and feedForward are run on push/PR (Python 3.9 and 3.13).
14+
## Scripts
1915

20-
---
16+
| Script | Run in CI | Description |
17+
|--------|-----------|-------------|
18+
| **PID.py** | Yes | Closed-loop (PI) test: runs a sequence of CPU targets, records dynamics, saves `pid_data` and PNGs (e.g. `PID.png`, `PID Actuation.png`). |
19+
| **feedForward.py** | Yes | Open-loop test: runs a sequence of sleep times, records dynamics, saves `feed_forward_data` and `FeedForward.png`. |
20+
| **Identification.py** | No | Identification script: sweep sleep times, estimate CPU load, scatter plots. Run manually from project root if needed. |
21+
| **Identification2.py** | No | Alternative identification: sleep-time sequence and dynamics plot. Run manually from project root if needed. |
2122

22-
## Issues and risks
23-
24-
### 1. ~~**macOS / cross-platform compatibility**~~ (fixed)
25-
26-
- **PID.py** and **feedForward.py** import `_available_cores` from `cpu_load_generator` (same logic as main script) → they work on macOS too.
27-
28-
### 2. ~~**Use of `monitor.running` (bug)**~~ (fixed)
29-
30-
- In **Identification.py** and **Identification2.py**, `monitor.running = 0` was replaced with **`monitor.stop()`** before `monitor.join()`.
31-
32-
### 3. ~~**Duplicate `monitor.join()` in Identification.py**~~ (fixed)
33-
34-
- Removed the redundant second `monitor.join()`; a single `monitor.stop()` + `monitor.join()`.
35-
36-
### 4. **Path and imports**
37-
38-
- All tests use `sys.path.insert(0, ...)` to import from the parent. This works when run from repo root; from `tests/` it may depend on cwd.
39-
- CI runs from repo root (`python tests/PID.py`), so it is fine. Running from `tests/` might not find `utils`.
40-
- Optional: use `python -m tests.PID` from root, or document “run from project root”.
41-
42-
### 5. **Files and figures written to cwd**
43-
44-
- Tests write `pid_data`, `feed_forward_data`, `identification_data`, `scatter_plot_data`, and PNGs (`PID.png`, `FeedForward.png`, `Identification.png`, etc.) to the **current working directory**.
45-
- In CI the cwd is the repo root → these files end up in the repo and could be committed by mistake (though `.gitignore` has `*.png`).
46-
- **Suggestion:** write to a subfolder (e.g. `tests/output/` or `tests/artifacts/`) and add it to `.gitignore`, or use `tempfile.gettempdir()`.
47-
48-
### 6. **No assertions**
49-
50-
- The tests do **not** use `assert` or a framework (pytest/unittest). They only check that the run does not crash and produce data/plots.
51-
- Useful as characterization/identification scripts; less so as automated tests (e.g. “controller reaches target within X seconds”).
52-
- Optional: add minimal checks (e.g. `assert len(dynamics['cpu']) > 0`, or thresholds on mean error) to make CI tests more robust.
53-
54-
### 7. **Identification and Identification2 not in CI**
55-
56-
- **Identification.py** and **Identification2.py** are not run by the CI workflow. If something breaks in Monitor/OpenLoopActuator, CI won’t catch it for these two.
57-
- **Suggestion:** include them in CI (at least as “run without crash”) or document that they are manual identification scripts.
58-
59-
### 8. **Unused variables**
60-
61-
- `dynamics_plot_online` is defined but not clearly used to change behaviour (in some places it is only passed to OpenLoopActuator as `plot`). It can be removed or used explicitly.
62-
63-
### 9. **Axis labels and titles**
64-
65-
- In several places the x-axis is labeled "Time [ms]" while the dynamics use `time.time()` (seconds). It should be "Time [s]" for consistency.
66-
67-
---
68-
69-
## Recommended actions summary
70-
71-
| Priority | Action |
72-
|----------|--------|
73-
| ~~High~~ | ~~Make PID.py and feedForward.py macOS-compatible~~ (done: `_available_cores` from `cpu_load_generator`). |
74-
| ~~High~~ | ~~Replace `monitor.running = 0` with `monitor.stop()`~~ (done in Identification and Identification2). |
75-
| Medium | Write data and PNGs to `tests/output/` (or similar) and add to `.gitignore`. |
76-
| Medium | Fix "Time [ms]" → "Time [s]" where data are in seconds. |
77-
| Low | Add Identification.py and Identification2.py to CI (at least as smoke). |
78-
| Low | Add some asserts or pytest to turn them into proper automated tests. |
79-
80-
---
81-
82-
## How to run the tests
23+
## How to run (same as CI)
8324

8425
From the **project root**:
8526

8627
```bash
8728
pip install -r requirements.txt
8829
python tests/PID.py
8930
python tests/feedForward.py
90-
python tests/Identification.py # optional, may open windows
91-
python tests/Identification2.py
9231
```
9332

94-
The tests are aligned with the main script changes (`cpu_load_generator.py`, cross-platform cores, monitor stop).
33+
Run from the project root so imports (`utils`, `cpu_load_generator`) resolve correctly.

0 commit comments

Comments
 (0)