291 lines
14 KiB
Python
291 lines
14 KiB
Python
"""Execute the real tuning engine against offline Arduino/motor test doubles."""
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
COMPILER = (Path.home() / ".platformio" / "packages" / "toolchain-gccmingw32" /
|
|
"bin" / "g++.exe")
|
|
|
|
ARDUINO = r'''
|
|
#pragma once
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <map>
|
|
#include <string>
|
|
#include <vector>
|
|
struct String {
|
|
std::string text;
|
|
String(const char* s) : text(s) {}
|
|
String(const std::string& s) : text(s) {}
|
|
String(int n) : text(std::to_string(n)) {}
|
|
};
|
|
inline String operator+(const char* a, const String& b) { return String(a + b.text); }
|
|
inline String operator+(const String& a, const char* b) { return String(a.text + b); }
|
|
extern unsigned long now_ms;
|
|
inline unsigned long millis() { return now_ms; }
|
|
extern std::vector<std::string> messages;
|
|
'''
|
|
|
|
CONFIG = r'''
|
|
#pragma once
|
|
struct ScopedMutexGuard { ScopedMutexGuard(int) {} };
|
|
constexpr int32_t LIMITX = -114000;
|
|
constexpr int32_t LIMITY = -116000;
|
|
'''
|
|
|
|
MOTOR = r'''
|
|
#pragma once
|
|
#include "Arduino.h"
|
|
struct Stepper { void moveToPositionInSteps(int32_t) {} };
|
|
extern Stepper stepperX, stepperY;
|
|
extern std::vector<std::string> nvs_keys;
|
|
extern std::map<std::string, int32_t> nvs_values;
|
|
class MotorDriver {
|
|
public:
|
|
static MotorDriver& instance() { static MotorDriver driver; return driver; }
|
|
int guard() { return 0; }
|
|
int32_t getPosX() const { return x; }
|
|
int32_t getPosY() const { return y; }
|
|
void setPosX(int32_t value) { x = value; }
|
|
void setPosY(int32_t value) { y = value; }
|
|
void waitMotionComplete() {}
|
|
void sendPositionUpdate() {}
|
|
void nvsPutInt(const char* key, int32_t) { nvs_keys.push_back(key); }
|
|
void nvsPutFloat(const char*, float) {}
|
|
void nvsPutBool(const char*, bool) {}
|
|
int32_t nvsGetInt(const char* key, int32_t fallback) { auto it=nvs_values.find(key); return it==nvs_values.end() ? fallback : it->second; }
|
|
float nvsGetFloat(const char*, float fallback) { return fallback; }
|
|
bool nvsGetBool(const char*, bool fallback) { return fallback; }
|
|
private:
|
|
int32_t x = -50000, y = -70000;
|
|
};
|
|
'''
|
|
|
|
HARNESS = r'''
|
|
#include "Arduino.h"
|
|
#include <limits>
|
|
#define private public
|
|
#include "tuning_engine.h"
|
|
#undef private
|
|
#include "motor_driver.h"
|
|
|
|
unsigned long now_ms = 1000;
|
|
std::vector<std::string> messages;
|
|
std::vector<std::string> nvs_keys;
|
|
std::map<std::string, int32_t> nvs_values;
|
|
Stepper stepperX, stepperY;
|
|
|
|
void require(bool condition, const char* message) {
|
|
if (!condition) { std::fprintf(stderr, "%s\n", message); std::exit(1); }
|
|
}
|
|
|
|
void prepare(TuningEngine& e, int32_t previous = 5000000) {
|
|
e.setLPFBeta(0.0f);
|
|
e.setErrMax(500);
|
|
e.setMinErrMax(100);
|
|
e.setPeakProximity(0.05f);
|
|
e.setCicleMax(20);
|
|
e._adcOld = -previous;
|
|
e._lpfValue = previous;
|
|
e._thermalInit = true;
|
|
e._irradBaseline = 100.0f;
|
|
e._cmaxNorm = 50000000;
|
|
e._spikeInit = true;
|
|
e._spikeLastC = previous;
|
|
e._spikeVar = 1.0e12f;
|
|
e._resetThreshold = std::numeric_limits<int32_t>::max();
|
|
e._abruptThreshold = std::numeric_limits<int32_t>::max();
|
|
}
|
|
|
|
int32_t tick(TuningEngine& e, int32_t current, float irradiance = 100.0f) {
|
|
int32_t before = e.getTotalSteps();
|
|
e.nomegusta(-current, e.getErrMax(), irradiance);
|
|
now_ms += 2000;
|
|
return e.getTotalSteps() - before;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
require(argc == 2, "Supply one scenario");
|
|
std::string test = argv[1];
|
|
auto& e = TuningEngine::instance();
|
|
prepare(e);
|
|
if (test == "delta") {
|
|
prepare(e, 5080492);
|
|
tick(e, 5083030);
|
|
require(messages.back().find("dC=2538,") != std::string::npos,
|
|
"Current change must be 2538, not the sum 10163522");
|
|
} else if (test == "filtered_delta") {
|
|
prepare(e, 4900000);
|
|
e.setLPFBeta(0.5f);
|
|
tick(e, 5000000);
|
|
require(messages.back().find("dC=100000,") != std::string::npos,
|
|
"Current must be compared with the previous accepted filtered reference");
|
|
} else if (test == "peak") {
|
|
require(tick(e, 5000000) == 100, "Peak must use minimum step");
|
|
} else if (test == "middle") {
|
|
require(tick(e, 4875000) == 300, "Mid-ramp must use midpoint step");
|
|
} else if (test == "boundary") {
|
|
require(tick(e, 4750000) == 500, "Near-window boundary must use maximum step");
|
|
} else if (test == "outside") {
|
|
e._smallThreshold = 0;
|
|
require(tick(e, 4700000) == 500, "Far branch must not jump back to a small step");
|
|
} else if (test == "new_peak") {
|
|
require(tick(e, 5100000) == 100, "New maximum must use minimum step");
|
|
} else if (test == "monotonic") {
|
|
int32_t previous_step = 0;
|
|
e._smallThreshold = 0;
|
|
for (int32_t current : {5000000, 4950000, 4900000, 4850000, 4800000, 4750000, 4700000, 4600000}) {
|
|
e._cicles = 0;
|
|
int32_t step = tick(e, current);
|
|
require(step >= previous_step && step >= 100 && step <= 500,
|
|
"Step must increase continuously with distance and remain bounded");
|
|
previous_step = step;
|
|
}
|
|
} else if (test == "zero_window" || test == "nan_window") {
|
|
e.setPeakProximity(test == "zero_window" ? 0.0f : std::numeric_limits<float>::quiet_NaN());
|
|
require(tick(e, 5000000) == 100, "Invalid window must not cause division by zero or invalid steps");
|
|
require(tick(e, 4900000) >= 100, "Invalid window must also be safe away from the peak");
|
|
} else if (test == "spike") {
|
|
e._spikeVar = 1.0f;
|
|
require(tick(e, 6000000) == 0, "Transient spike must not move motors");
|
|
require(e._adcOld == -5000000, "Rejected spike must not replace or invert the accepted reference");
|
|
require(e._lpfValue == 5000000, "Rejected spike must not contaminate the LPF");
|
|
} else if (test == "deadband_escape") {
|
|
e._cmaxNorm = 100000000;
|
|
e._cicles = 1;
|
|
e._smallThreshold = std::numeric_limits<int32_t>::max();
|
|
for (int i = 0; i < 9; ++i)
|
|
require(tick(e, 5000000) == 0, "Deadband should initially suppress motion");
|
|
require(tick(e, 5000000) == 500, "Tenth deadband cycle must escape instead of freezing forever");
|
|
} else if (test == "steady_peak") {
|
|
for (int i = 0; i < 20; ++i)
|
|
require(tick(e, 5000000) == 100, "Healthy near-peak tracking must not trigger coarse escape steps");
|
|
} else if (test == "standby") {
|
|
require(tick(e, 5000000, 0.0f) == 0, "Low-light standby must not move motors");
|
|
require(e.isLowLight(), "Low-light state must still be reported");
|
|
} else if (test == "axis_base1_3to1") {
|
|
require(e.setCicleMax(1), "base 1 accepted");
|
|
e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
|
|
for (int i=0;i<3;++i) tick(e, 4900000);
|
|
require(!e._xActive && e._axisXCount==3, "base 1 ratio 3:1 actuates 3 X iterations");
|
|
tick(e, 4900000);
|
|
require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "base 1 ratio 3:1 actuates 1 Y iteration");
|
|
} else if (test == "axis_base4_3to1") {
|
|
e.setCicleMax(4); require(e.setAxisXFactor(3) && e.setAxisYFactor(1), "3:1 factors accepted");
|
|
require(e.getAxisXBudget() == 12 && e.getAxisYBudget() == 4, "base 4 yields 12 X and 4 Y iterations");
|
|
e._smallThreshold=0;
|
|
for (int i=0;i<12;++i) tick(e, 4900000);
|
|
require(!e._xActive && e._axisXCount==12, "X receives twelve actual fine-tune actuation iterations");
|
|
for (int i=0;i<4;++i) tick(e, 4900000);
|
|
require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "Y receives four iterations then schedule repeats");
|
|
} else if (test == "axis_symmetric") {
|
|
e.setCicleMax(1); e.setAxisXFactor(1); e.setAxisYFactor(1); e._smallThreshold=0;
|
|
tick(e, 4900000); require(!e._xActive && e._axisXCount==1, "1:1 schedules one X before Y");
|
|
tick(e, 4900000); require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "1:1 schedules one Y then repeats");
|
|
} else if (test == "axis_runtime_change") {
|
|
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
|
|
for (int i=0;i<3;++i) tick(e, 4900000);
|
|
require(e._axisXCount==3, "initial schedule has pending X iterations");
|
|
require(e.setAxisXFactor(2), "runtime factor change accepted");
|
|
require(e._xActive && e._axisXCount==0 && e._axisYCount==0 && e.getAxisXBudget()==8,
|
|
"runtime change restarts with exact recalculated budget");
|
|
require(!e.setCicleMax(0) && !e.setCicleMax(-1) && !e.setCicleMax(65), "invalid base counts rejected");
|
|
require(!e.setAxisXFactor(9) && !e.setAxisYFactor(0), "invalid factors rejected");
|
|
} else if (test == "axis_nvs_restore") {
|
|
nvs_values["ciclemx"]=4; nvs_values["axisxf"]=3; nvs_values["axisyf"]=1;
|
|
e.loadFromNVS();
|
|
require(e.getCicleMax()==4 && e.getAxisXFactor()==3 && e.getAxisYFactor()==1 &&
|
|
e.getAxisXBudget()==12 && e.getAxisYBudget()==4, "valid saved schedule is restored unchanged");
|
|
nvs_values["ciclemx"]=10; nvs_values["axisxf"]=8; nvs_values["axisyf"]=1;
|
|
e.loadFromNVS();
|
|
require(e.getCicleMax()==10 && e.getAxisXFactor()==1 && e.getAxisYFactor()==1 && e.isAxisConfigValid(),
|
|
"conflicting saved factors sanitize to safe 1:1 while retaining valid base");
|
|
require(messages.back().find("AXISCFG:1:1:10")!=std::string::npos, "sanitized state reports only actual applied schedule");
|
|
nvs_values["ciclemx"]=0; nvs_values["axisxf"]=3; nvs_values["axisyf"]=1;
|
|
e.loadFromNVS();
|
|
require(e.getCicleMax()==5 && e.getAxisXFactor()==3 && e.getAxisYFactor()==1,
|
|
"invalid saved base falls back safely and retains compatible valid factors");
|
|
} else if (test == "axis_skipped_samples") {
|
|
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1);
|
|
e._spikeVar=1.0f;
|
|
tick(e, 6000000);
|
|
require(e._axisXCount==0 && e._axisYCount==0, "rejected spike does not consume axis budget");
|
|
e._spikeVar=1.0e12f; e._cicles=1; e._cmaxNorm=100000000;
|
|
e._smallThreshold=std::numeric_limits<int32_t>::max();
|
|
for(int i=0;i<9;++i) tick(e,5000000);
|
|
require(e._axisXCount==0 && e._axisYCount==0, "deadband-suppressed reads do not consume axis budget");
|
|
} else if (test == "axis_standby_recovery") {
|
|
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
|
|
tick(e,4900000);
|
|
require(e._axisXCount==1, "first lit sample actuates X");
|
|
tick(e,4900000,0.0f);
|
|
require(e._axisXCount==0 && e._axisYCount==0 && e._xActive, "standby clears partial schedule");
|
|
tick(e,4900000,100.0f);
|
|
require(e._axisXCount==1 && e._xActive, "light recovery starts a fresh X schedule");
|
|
} else if (test == "axis_limit_release") {
|
|
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
|
|
MotorDriver::instance().setPosX(-100);
|
|
require(tick(e,4700000)>0, "limit test performs a fine-tune actuation");
|
|
require(MotorDriver::instance().getPosX()==0 && !e._xActive,
|
|
"X travel limit immediately yields the next turn to Y");
|
|
} else if (test == "constant_step") {
|
|
e.setErrMax(200);
|
|
e.setMinErrMax(200);
|
|
e._smallThreshold = 0;
|
|
for (int32_t current : {5000000, 4875000, 4750000, 4700000})
|
|
require(tick(e, current) == 200, "Equal minimum/maximum must preserve fixed-step operation");
|
|
} else {
|
|
require(false, "Unknown scenario");
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
class TuningEngineTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
if not COMPILER.is_file():
|
|
raise unittest.SkipTest("PlatformIO native MinGW compiler is not installed")
|
|
cls.temp = tempfile.TemporaryDirectory(prefix="tuning-engine-test-")
|
|
cls.addClassCleanup(cls.temp.cleanup)
|
|
directory = Path(cls.temp.name)
|
|
# Copy only the control code; never include private network credentials or hardware drivers.
|
|
for name in ("tuning_engine.cpp", "tuning_engine.h"):
|
|
(directory / name).write_bytes((ROOT / "src" / name).read_bytes())
|
|
for name, text in {"Arduino.h": ARDUINO, "config.h": CONFIG,
|
|
"motor_driver.h": MOTOR, "harness.cpp": HARNESS,
|
|
"log_manager.h": '#pragma once\n#include "Arduino.h"\ninline void agregarLog(const String& s) { messages.push_back(s.text); }\n'}.items():
|
|
(directory / name).write_text(text, encoding="utf-8")
|
|
cls.exe = directory / "tuning-test.exe"
|
|
arguments = [str(COMPILER), "-std=c++11", "-static", "-O0", "-I", str(directory), "harness.cpp",
|
|
"tuning_engine.cpp", "-o", str(cls.exe)]
|
|
environment = os.environ.copy()
|
|
environment["PATH"] = str(COMPILER.parent) + os.pathsep + environment.get("PATH", "")
|
|
result = subprocess.run(arguments, cwd=directory, env=environment,
|
|
capture_output=True, text=True, timeout=90)
|
|
if result.returncode:
|
|
raise AssertionError(f"Compiler exit {result.returncode}:\n{result.stdout}{result.stderr}")
|
|
|
|
def scenario(self, name):
|
|
result = subprocess.run([str(self.exe), name], capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
for _name in ("delta", "filtered_delta", "peak", "middle", "boundary", "outside", "new_peak",
|
|
"monotonic", "zero_window", "nan_window", "spike", "deadband_escape", "steady_peak",
|
|
"standby", "constant_step", "axis_base1_3to1", "axis_base4_3to1", "axis_symmetric",
|
|
"axis_runtime_change", "axis_nvs_restore", "axis_skipped_samples", "axis_standby_recovery",
|
|
"axis_limit_release"):
|
|
setattr(TuningEngineTests, "test_" + _name, lambda self, name=_name: self.scenario(name))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|