"""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 #include #include #include #include #include #include #include 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 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 nvs_keys; extern std::map 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 #define private public #include "tuning_engine.h" #undef private #include "motor_driver.h" unsigned long now_ms = 1000; std::vector messages; std::vector nvs_keys; std::map 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::max(); e._abruptThreshold = std::numeric_limits::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::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::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::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)