fwt_software/tests/test_scheduler.cpp

828 lines
29 KiB
C++

#include <doctest/doctest.h>
#include "fgc/CaptureScheduler.h"
#include <string>
#include <vector>
using namespace fgc;
namespace {
struct FakeMotor : IMotorController {
MotorTelemetry tel;
std::vector<std::string> cmds;
FakeMotor() { tel.pitch_present = true; } // 2-axis rig by default; 1-axis tests clear this
void start() override {}
void stop() override {}
void sendCommand(const std::string& c) override { cmds.push_back(c); }
MotorTelemetry telemetry() override { return tel; }
std::string lastDump() override { return ""; }
std::string lastDiag() override { return ""; }
unsigned diagSeq() const override { return 0; }
bool connected() const override { return true; }
};
struct FakeCamera : ICameraSource {
int triggers = 0;
bool ok = true;
// Automatic settle wait (settle_wait_ms = 0): what the camera reports, and how
// often a watch was begun. Settled by default, so 0 means "no wait" unless a
// test says otherwise.
ExposureState exposure = ExposureState::Settled;
int watches = 0;
void beginExposureWatch() override { ++watches; }
ExposureState exposureState() override { return exposure; }
void open() override {}
void close() override {}
void start() override {}
void stop() override {}
bool trigger() override { ++triggers; return ok; }
void setFrameCallback(FrameCallback) override {}
int cameraCount() const override { return 1; }
};
// Simple linear map: 10 counts/deg, centre at count 0 pointing at 0 deg.
// Reachable yaw travel -90..90, pitch 0..60.
// Fields: counts_per_deg, center_count, deg_at_center, min_deg, max_deg.
Geometry testGeometry() {
Geometry g;
g.yaw = {10.0, 0, 0.0, -90.0, 90.0};
g.pitch = {10.0, 0, 0.0, 0.0, 60.0};
return g;
}
// Mark both axes settled at the scheduler's current target.
void settleAt(FakeMotor& m, long yaw, long pitch) {
m.tel.yaw.xenc = yaw; m.tel.yaw.standstill = true; m.tel.yaw.state = AxisState::Ready;
m.tel.pitch.xenc = pitch; m.tel.pitch.standstill = true; m.tel.pitch.state = AxisState::Ready;
m.tel.pitch_present = true;
}
} // namespace
TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); // -> yaw counts 0, then 900
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 0); // automatic, and the fake is settled: no exposure wait
sch.setMode(Mode::Auto);
// The move goes out straight away: MOVE to the first grid point (0,0).
clock = 10;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "MOVE 0,0");
CHECK(sch.moving());
CHECK(cam.triggers == 0); // not yet settled
// At rest early: the frame waits for the interval, counted from the mode start.
settleAt(motor, 0, 0);
clock = 100;
sch.tick(); // records standstill
clock = 200;
sch.tick(); // exposure settled, interval not over
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 1000;
sch.tick(); // interval over: the frame is kept, the cursor advances
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving());
// And the next move goes out on the next tick: (90 deg -> 900 counts).
clock = 1010;
sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0");
CHECK(sch.overruns() == 0);
}
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
long long clock = 0;
FakeMotor motor;
motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setMode(Mode::Auto);
// The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects.
clock = 1600;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "MOVE Y 0");
// Settle on yaw only (pitch never reports standstill on this rig) -> capture fires.
motor.tel.yaw.xenc = 0;
motor.tel.yaw.standstill = true;
motor.tel.yaw.state = AxisState::Ready;
clock = 1700;
sch.tick();
clock = 1710;
sch.tick();
CHECK(cam.triggers == 1);
clock = 2800;
sch.tick();
CHECK(motor.cmds.back() == "MOVE Y 900");
}
// Walk one capture: the interval elapses and a MOVE goes out, then the gimbal
// settles on it and the frame is kept. Returns the MOVE.
std::string captureOnce(CaptureScheduler& sch, FakeMotor& motor, long long& clock) {
clock += 1100;
sch.tick();
REQUIRE(!motor.cmds.empty());
const std::string move = motor.cmds.back();
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
clock += 10;
sch.tick(); // records standstill
clock += 10;
sch.tick(); // keeps the frame
return move;
}
TEST_CASE("pan walks its positions left to right, then jumps back to the left edge") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
sch.setPanStep(6.0);
// 0 +/- 15 at 6 deg: 345, 351, 357, 3, 9, 15 - six pictures, across north.
sch.setMode(Mode::Pan, PanSpec{0.0, 30.0, 20.0});
REQUIRE(sch.panHeadingsNow().size() == 6);
std::vector<std::string> moves;
for (int i = 0; i < 7; ++i) moves.push_back(captureOnce(sch, motor, clock));
// 10 counts/deg; pitch 20 deg -> 200 counts, whatever pitch the gimbal had.
CHECK(moves[0] == "MOVE -150,200");
CHECK(moves[1] == "MOVE -90,200");
CHECK(moves[2] == "MOVE -30,200");
CHECK(moves[3] == "MOVE 30,200");
CHECK(moves[4] == "MOVE 90,200");
CHECK(moves[5] == "MOVE 150,200");
CHECK(moves[6] == "MOVE -150,200"); // back to the left edge
CHECK(cam.triggers == 7);
}
TEST_CASE("a pan narrower than one picture stays on its heading") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
sch.setMode(Mode::Pan, PanSpec{45.0, 5.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0");
CHECK(cam.triggers == 2);
}
TEST_CASE("a pan skips positions outside the reachable arc instead of clamping them") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
// 84, 90, 96: the last is past the 90 deg limit. Clamping it to 90 would
// image 90 twice and label one of them 96.
sch.setMode(Mode::Pan, PanSpec{90.0, 12.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0");
}
TEST_CASE("a pan with nothing reachable moves nothing") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
// 200 deg is 160 deg the short way round, past the 90 deg limit.
sch.setMode(Mode::Pan, PanSpec{200.0, 0.0, 0.0});
clock = 1600;
sch.tick();
CHECK(motor.cmds.empty());
CHECK_FALSE(sch.moving());
}
TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") {
// A `gimbal heading fit` updates the live geometry; the scheduler must adopt it so
// the pan targets use the calibrated counts/deg (not the stale copy).
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
sch.setMode(Mode::Pan, PanSpec{45.0, 0.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0"); // 45 * 10 counts/deg
// Recalibrate to 20 counts/deg; the same heading must now resolve to twice
// the counts.
Geometry g2;
g2.yaw = {20.0, 0, 0.0, -90.0, 90.0};
g2.pitch = {10.0, 0, 0.0, 0.0, 60.0};
sch.setGeometry(g2);
CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0"); // 45 * 20 counts/deg
}
TEST_CASE("CaptureScheduler stays idle in manual mode") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
// manual: the mode it starts in
clock = 5000;
sch.tick();
CHECK(motor.cmds.empty());
CHECK(cam.triggers == 0);
}
TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a frame") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto);
clock = 10;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
// Standstill starts the wait; the camera is not touched during it.
settleAt(motor, 0, 0);
clock = 2000;
sch.tick();
CHECK(cam.triggers == 0);
CHECK(cam.watches == 0); // a fixed wait never asks the camera
CHECK(std::string(sch.phase()) == "settling");
clock = 2900; // 900 ms after settling: still inside the wait
sch.tick();
CHECK(cam.triggers == 0);
// Past the wait, but the interval still has time left: wait it out.
clock = 3100;
sch.tick();
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 5000;
sch.tick();
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving());
}
TEST_CASE("the interval is counted from the last frame, so pictures come one interval apart") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.5 /*2000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
std::vector<long long> shots;
// 10 ms ticks; the gimbal takes 700 ms to reach each target.
long long arrive = -1;
size_t moves = 0;
for (clock = 0; clock <= 7000; clock += 10) {
if (motor.cmds.size() != moves) {
moves = motor.cmds.size();
arrive = clock + 700;
motor.tel.yaw.standstill = false;
}
if (arrive >= 0 && clock >= arrive)
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
const int before = cam.triggers;
sch.tick();
if (cam.triggers != before) shots.push_back(clock);
}
// Move 700 + settle 500 fit inside 2000, so frames land 2000 ms apart.
REQUIRE(shots.size() >= 3);
CHECK(shots[0] == 2000);
CHECK(shots[1] == 4000);
CHECK(shots[2] == 6000);
CHECK(sch.overruns() == 0);
}
TEST_CASE("a waypoint whose move and settle outlast the interval warns, and shoots when ready") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
// First waypoint: the move from wherever the gimbal was is quiet even when long.
clock = 0; sch.tick(); // MOVE 0,0
clock = 3000; settleAt(motor, 0, 0); sch.tick();
clock = 3500; sch.tick(); // settled + 500: kept at once
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0);
// Second: 900 ms of travel + 500 settle > 1000 ms interval.
clock = 3510; sch.tick(); // MOVE 300,0
REQUIRE(motor.cmds.back() == "MOVE 300,0");
motor.tel.yaw.standstill = false;
clock = 4410; settleAt(motor, 300, 0); sch.tick();
clock = 4910; sch.tick(); // 1410 ms since the last frame
CHECK(cam.triggers == 2); // no extra wait: shot as soon as ready
CHECK(sch.overruns() == 1);
}
TEST_CASE("no overrun warning on the return from the last waypoint to the first") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
// Waypoint 1 (first move: quiet), waypoint 2 within the interval.
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
CHECK(cam.triggers == 1);
clock = 1010; sch.tick(); // MOVE 300,0
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 1200; sch.tick(); // ready well inside the interval
clock = 2010; sch.tick();
CHECK(cam.triggers == 2);
// The long way back to waypoint 1: well over the interval, but quiet.
clock = 2020; sch.tick();
REQUIRE(motor.cmds.back() == "MOVE 0,0");
motor.tel.yaw.standstill = false;
clock = 6000; settleAt(motor, 0, 0); sch.tick();
clock = 6010; sch.tick();
CHECK(cam.triggers == 3);
CHECK(sch.overruns() == 0);
// Waypoint 2 again, also too slow: this one warns.
clock = 6020; sch.tick();
motor.tel.yaw.standstill = false;
clock = 8000; settleAt(motor, 300, 0); sch.tick();
clock = 8010; sch.tick();
CHECK(cam.triggers == 4);
CHECK(sch.overruns() == 1);
}
TEST_CASE("an unreachable waypoint is skipped without restarting the interval") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
// 150 deg is past the 90 deg limit.
ScanGrid grid({{0.0, 0.0}, {150.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
REQUIRE(cam.triggers == 1);
// Straight past waypoint 2 to waypoint 3 in one tick.
clock = 1010; sch.tick();
CHECK(motor.cmds.back() == "MOVE 300,0");
CHECK(grid.index() == 2);
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 2000; sch.tick(); // one interval after the last frame
CHECK(cam.triggers == 2);
}
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.ok = false; // camera never has a fresh frame
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto);
clock = 10;
sch.tick();
settleAt(motor, 0, 0);
clock = 100;
sch.tick();
// Due at 1100 (settled + 1000 > interval); retries through the timeout window.
clock = 1100;
sch.tick();
clock = 2900; // 1800 ms after falling due: inside the timeout
sch.tick();
CHECK(sch.moving());
CHECK(grid.index() == 0);
// Past the timeout, the waypoint is skipped rather than blocking the sweep.
clock = 3200;
sch.tick();
CHECK_FALSE(sch.moving());
CHECK(grid.index() == 1);
}
// --- automatic settle wait (settle_wait_ms = 0) ----------------------------
TEST_CASE("automatic settle waits until the camera reports the exposure settled") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.exposure = ExposureState::Converging;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 3000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
CHECK(cam.watches == 1); // the watch begins at standstill
for (clock = 200; clock <= 2000; clock += 100) sch.tick();
CHECK(cam.triggers == 0); // still converging
cam.exposure = ExposureState::Settled;
clock = 2100; sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("automatic settle proceeds when the exposure is pinned at its limits") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.exposure = ExposureState::AtLimit;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 3000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("automatic settle keeps the frame anyway after frame_timeout_ms") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.exposure = ExposureState::Converging; // never settles
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 3000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 3000; sch.tick();
CHECK(cam.triggers == 0);
clock = 3100; sch.tick(); // 3000 ms after standstill
CHECK(cam.triggers == 1);
CHECK(grid.index() == 1);
}
TEST_CASE("automatic settle falls back to a fixed wait when the camera cannot tell") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.exposure = ExposureState::Unknown;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 3000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
CHECK(std::string(sch.phase()) == "settling");
clock = 1100; sch.tick(); // standstill + kFallbackSettleMs
CHECK(std::string(sch.phase()) == "waiting");
}
// --- manual snapshot -------------------------------------------------------
//
// The snapshot exists so an operator aiming by hand gets exactly what a scan
// waypoint produces. These pin the two properties that make that true: it goes
// through the same settle gate, and it never overlaps a scan.
TEST_CASE("requestSnapshot captures one frame once the gimbal has settled") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
// Capture inactive: this is manual mode.
settleAt(motor, 0, 0);
sch.requestSnapshot();
CHECK(sch.snapshotPending());
clock = 10;
sch.tick(); // first standstill seen; starts the settle clock
CHECK(cam.triggers == 0);
clock = 500;
sch.tick(); // still inside the settle wait
CHECK(cam.triggers == 0);
clock = 1100;
sch.tick(); // settle wait elapsed
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.snapshotPending());
// One request, one image: further ticks must not keep firing.
clock = 5000;
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a snapshot waits out a slew rather than shooting mid-move") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 60000); // long frame budget: the point here is the settle
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // a jog is in flight
sch.requestSnapshot();
for (clock = 10; clock < 3000; clock += 500) sch.tick();
CHECK(cam.triggers == 0); // never fires while moving
motor.tel.yaw.standstill = true;
clock = 3100;
sch.tick(); // standstill seen, clock starts here
CHECK(cam.triggers == 0);
clock = 4200;
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
settleAt(motor, 0, 0);
sch.requestSnapshot();
sch.setMode(Mode::Auto);
// Arming is dropped, not deferred: firing it whenever the scan happens to end
// would attribute an image to whatever the gimbal was pointing at then.
CHECK_FALSE(sch.snapshotPending());
sch.requestSnapshot(); // asked for again mid-scan
clock = 1600;
sch.tick(); // the scan's own move, not a snapshot
sch.setMode(Mode::Manual);
clock = 3000;
sch.tick();
CHECK_FALSE(sch.snapshotPending());
}
TEST_CASE("the snapshot frame budget is measured from settling, not from the keypress") {
// Pressing Space mid-slew must not spend the camera's budget on the slew.
// Before this was fixed, a long jog could exhaust the timeout before the
// gimbal had even stopped, and the snapshot was abandoned having never asked
// the camera for anything.
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // a long slew is in flight
sch.requestSnapshot();
// Slew for far longer than the whole settle+timeout budget.
for (clock = 10; clock < 30000; clock += 500) sch.tick();
CHECK(sch.snapshotPending()); // still armed, not timed out
CHECK(cam.triggers == 0);
motor.tel.yaw.standstill = true;
clock = 30500;
sch.tick(); // standstill seen; the budget starts HERE
clock = 31600; // settle wait elapsed
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a snapshot that times out can simply be asked for again") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);
cam.ok = false; // camera has no frame to give
sch.requestSnapshot();
for (clock = 10; clock < 6000; clock += 250) sch.tick();
CHECK_FALSE(sch.snapshotPending()); // gave up rather than staying armed forever
const int attempts = cam.triggers;
CHECK(attempts > 0); // it did keep retrying within the budget
// It must not keep trying after giving up.
for (clock = 6000; clock < 12000; clock += 250) sch.tick();
CHECK(cam.triggers == attempts);
// And the next press starts a fresh budget and succeeds.
cam.ok = true;
sch.requestSnapshot();
clock = 12500;
sch.tick();
clock = 13600;
sch.tick();
CHECK(cam.triggers == attempts + 1);
CHECK_FALSE(sch.snapshotPending());
}
// --- continuous capture (`capture on`) -------------------------------------
//
// The scan's cycle without the move: standstill + settle, then the rest of the
// interval since the last frame, then one frame - so the cadence is what a scan
// achieves, and the gimbal stays free to be aimed in between.
TEST_CASE("continuous capture keeps one frame per interval, in manual") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
CHECK(sch.continuous());
clock = 100; sch.tick(); // standstill seen, settle starts
clock = 700; sch.tick(); // settled, interval not over
CHECK(cam.triggers == 0);
clock = 1000; sch.tick(); // interval over: shoot
CHECK(cam.triggers == 1);
CHECK(motor.cmds.empty()); // never moves the gimbal
// The timer restarted at the capture, so the next frame is one interval on.
clock = 1100; sch.tick(); // standstill seen
clock = 1700; sch.tick(); // settled
clock = 1900; sch.tick();
CHECK(cam.triggers == 1);
clock = 2000; sch.tick();
CHECK(cam.triggers == 2);
CHECK(sch.overruns() == 0);
}
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(500, 60000);
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // the operator is nudging
sch.setContinuous(true);
for (clock = 1100; clock < 5000; clock += 250) sch.tick();
CHECK(cam.triggers == 0);
motor.tel.yaw.standstill = true;
clock = 5100; sch.tick(); // standstill: settle clock starts here
CHECK(cam.triggers == 0);
clock = 5700; sch.tick();
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0); // no waypoints, no overrun warnings
}
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.ok = false;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 1000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
clock = 100; sch.tick(); // standstill seen
clock = 1000; sch.tick(); // due: tries, no frame
CHECK(cam.triggers == 1);
clock = 2000; sch.tick(); // past the budget: give up, timer restarts
const int after_giving_up = cam.triggers;
clock = 2500; sch.tick(); // standstill seen; next frame not due yet
clock = 2600; sch.tick();
CHECK(cam.triggers == after_giving_up);
CHECK(sch.continuous()); // still on
cam.ok = true;
clock = 3000; sch.tick(); // one interval after giving up
CHECK(cam.triggers == after_giving_up + 1);
}
TEST_CASE("any mode change ends continuous capture") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
settleAt(motor, 0, 0);
sch.setContinuous(true);
sch.setMode(Mode::Auto);
CHECK_FALSE(sch.continuous());
sch.setMode(Mode::Manual); // coming back does not resume it
CHECK_FALSE(sch.continuous());
sch.setContinuous(true);
sch.setMode(Mode::Manual);
CHECK_FALSE(sch.continuous());
}
TEST_CASE("a snapshot during continuous capture fires once, then capture carries on") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 1000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
sch.requestSnapshot();
clock = 10; sch.tick(); // snapshot: standstill seen
clock = 20; sch.tick(); // snapshot fires, well before the interval
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.snapshotPending());
CHECK(sch.continuous());
clock = 1100; sch.tick(); // continuous: standstill seen
clock = 1200; sch.tick();
CHECK(cam.triggers == 2);
}
TEST_CASE("a snapshot honours the automatic settle wait") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.exposure = ExposureState::Converging;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 5000);
settleAt(motor, 0, 0);
sch.requestSnapshot();
clock = 10; sch.tick(); // standstill: the watch begins
CHECK(cam.watches == 1);
clock = 2000; sch.tick();
CHECK(cam.triggers == 0);
cam.exposure = ExposureState::Settled;
clock = 2100; sch.tick();
CHECK(cam.triggers == 1);
}