Files

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)