controller: restructure control loop

This should have been multiple commits, but it isn't. Sue me. This
change has two main goals:

1. Sample feedback at the beginning of the control loop iteration so
   that it is always up-to-date when we are computing the actual drive
   outputs. This means we're doing twice the amount of communication
   with the labjack (previously, setting the output and reading the
   feedback was done with a singe command). However, this makes the
   loop structure much more standard, and it means that we aren't
   constantly operating on feedback that is stale by one loop
   interval.

2. Sample feedback into a (configurable size) buffer. This lets us
   operate on aggregated feedback rather than on a single instantaneous
   data point. Right now, feedback is computed as a moving average,
   which acts as a rudimentary low-pass filter, reducing spurious
   single-loop actions due to feedback spikes or other noise. However,
   the other reason to aggregate some backwards data is that it will
   let us do automatic stall detection in a simple way, although that
   is not currently done.
This commit is contained in:
torque 2024-07-18 19:34:58 -07:00
parent 153dde40aa
commit 61c10df63d
Signed by: torque
SSH Key Fingerprint: SHA256:nCrXefBNo6EbjNSQhv0nXmEg/VuNq3sMF5b8zETw3Tk
3 changed files with 171 additions and 81 deletions

View File

@ -127,6 +127,7 @@ controller: ControllerConfig = .{
// this is a symmetric mask, so the minimum usable elevation is elevation_mask deg // this is a symmetric mask, so the minimum usable elevation is elevation_mask deg
// and the maximum usable elevation is 180 - elevation_mask deg // and the maximum usable elevation is 180 - elevation_mask deg
.elevation_mask = 0.0, .elevation_mask = 0.0,
.feedback_window_samples = 3,
}, },
pub const VoltAngle = struct { voltage: f64, angle: f64 }; pub const VoltAngle = struct { voltage: f64, angle: f64 };
@ -179,6 +180,8 @@ const ControllerConfig = struct {
angle_offset: AzEl, angle_offset: AzEl,
elevation_mask: f64, elevation_mask: f64,
feedback_window_samples: u8,
const OutPair = struct { const OutPair = struct {
increase: lj.DigitalOutputChannel, increase: lj.DigitalOutputChannel,
decrease: lj.DigitalOutputChannel, decrease: lj.DigitalOutputChannel,

View File

@ -36,19 +36,16 @@ pub fn calibrate(allocator: std.mem.Allocator, routine: CalibrationRoutine) !voi
} }
pub fn init(allocator: std.mem.Allocator) !YaesuController { pub fn init(allocator: std.mem.Allocator) !YaesuController {
const lock = try allocator.create(std.Thread.Mutex);
errdefer allocator.destroy(lock);
lock.* = .{};
const controller = try allocator.create(Controller); const controller = try allocator.create(Controller);
errdefer allocator.destroy(controller); errdefer allocator.destroy(controller);
controller.init(lock); controller.* = try Controller.init(allocator);
errdefer controller.deinit(allocator);
// do this in the main thread so we can throw the error about it synchronously. // do this in the main thread so we can throw the error about it synchronously.
try controller.connectLabjack(); try controller.connectLabjack();
return .{ return .{
.control_thread = try std.Thread.spawn(.{}, runController, .{controller}), .control_thread = try std.Thread.spawn(.{}, runController, .{controller}),
.lock = lock, .lock = &controller.lock,
.controller = controller, .controller = controller,
}; };
} }
@ -101,16 +98,15 @@ pub fn currentPosition(self: YaesuController) AzEl {
return self.controller.position; return self.controller.position;
} }
pub fn startCalibration(self: YaesuController) void { pub fn waitForUpdate(self: YaesuController) AzEl {
// there are two different types of calibration: const controller = @constCast(self.controller);
// 1. feedback calibration, running to the extents of the rotator
// 2. sun calibration, which determines the azimuth and elevation angle self.lock.lock();
// offset between the rotator's physical stops and geodetic north defer self.lock.unlock();
//
// The former is (fairly) trivial to automate, just run until stall controller.condition.wait(self.lock);
// (assuming there's no deadband in the feedback). The latter requires
// manual input as the human is the feedback hardware in the loop. return controller.position;
_ = self;
} }
pub fn quit(self: YaesuController) void { pub fn quit(self: YaesuController) void {
@ -155,16 +151,77 @@ fn runController(controller: *Controller) void {
}; };
} }
const FeedbackBuffer = struct {
samples: []f64,
index: usize = 0,
fn initZero(allocator: std.mem.Allocator, samples: usize) !FeedbackBuffer {
const buf = try allocator.alloc(f64, samples * 2);
@memset(buf, 0);
return .{ .samples = buf };
}
fn deinit(self: FeedbackBuffer, allocator: std.mem.Allocator) void {
allocator.free(self.samples);
}
fn push(self: *FeedbackBuffer, sample: [2]lj.AnalogReadResult) void {
const halfpoint = @divExact(self.samples.len, 2);
defer self.index = (self.index + 1) % halfpoint;
self.samples[self.index] = sample[0].voltage;
self.samples[self.index + halfpoint] = sample[1].voltage;
}
inline fn mean(data: []f64) f64 {
var accum: f64 = 0;
for (data) |pt| {
accum += pt;
}
return accum / @as(f64, @floatFromInt(data.len));
}
fn lerp(input: f64, cal_points: Config.MinMax) f64 {
return (input - cal_points.minimum.voltage) * cal_points.slope() + cal_points.minimum.angle;
}
fn get(self: FeedbackBuffer) AzEl {
const halfpoint = @divExact(self.samples.len, 2);
return .{
.azimuth = lerp(
mean(self.samples[0..halfpoint]),
config.labjack.feedback_calibration.azimuth,
) + config.controller.angle_offset.azimuth,
.elevation = lerp(
mean(self.samples[halfpoint..]),
config.labjack.feedback_calibration.elevation,
) + config.controller.angle_offset.elevation,
};
}
fn getRaw(self: FeedbackBuffer) AzEl {
const halfpoint = @divExact(self.samples.len, 2);
return .{
.azimuth = mean(self.samples[0..halfpoint]),
.elevation = mean(self.samples[halfpoint..]),
};
}
};
const Controller = struct { const Controller = struct {
target: AzEl, target: AzEl,
position: AzEl, position: AzEl,
feedback_buffer: FeedbackBuffer,
current_state: ControllerState, current_state: ControllerState,
requested_state: ControllerState, requested_state: ControllerState,
lock: *std.Thread.Mutex,
labjack: lj.Labjack, labjack: lj.Labjack,
lock: std.Thread.Mutex = .{},
condition: std.Thread.Condition = .{},
const ControllerState = enum { const ControllerState = enum {
initializing, initializing,
idle, idle,
@ -173,13 +230,13 @@ const Controller = struct {
stopped, stopped,
}; };
fn init(self: *Controller, lock: *std.Thread.Mutex) void { fn init(allocator: std.mem.Allocator) !Controller {
self.* = .{ return .{
.target = .{ .azimuth = 0, .elevation = 0 }, .target = .{ .azimuth = 0, .elevation = 0 },
.position = .{ .azimuth = 0, .elevation = 0 }, .position = .{ .azimuth = 0, .elevation = 0 },
.feedback_buffer = try FeedbackBuffer.initZero(allocator, config.controller.feedback_window_samples),
.current_state = .stopped, .current_state = .stopped,
.requested_state = .idle, .requested_state = .idle,
.lock = lock,
.labjack = switch (config.labjack.device) { .labjack = switch (config.labjack.device) {
.autodetect => lj.Labjack.autodetect(), .autodetect => lj.Labjack.autodetect(),
.serial_number => |sn| lj.Labjack.with_serial_number(sn), .serial_number => |sn| lj.Labjack.with_serial_number(sn),
@ -187,6 +244,10 @@ const Controller = struct {
}; };
} }
fn deinit(self: Controller, allocator: std.mem.Allocator) void {
self.feedback_buffer.deinit(allocator);
}
fn connectLabjack(self: *Controller) !void { fn connectLabjack(self: *Controller) !void {
const info = try self.labjack.connect(); const info = try self.labjack.connect();
try self.labjack.setAllDigitalOutputLow(); try self.labjack.setAllDigitalOutputLow();
@ -233,26 +294,23 @@ const Controller = struct {
.positive; .positive;
} }
fn updateAzEl(self: *const Controller) !AzEl { fn updateFeedback(self: *Controller) !void {
const inputs = .{ config.controller.azimuth_input, config.controller.elevation_input }; const inputs = .{
config.controller.azimuth_input,
config.controller.elevation_input,
};
const raw = try self.labjack.readAnalogWriteDigital( const raw = try self.labjack.readAnalogWriteDigital(
2, 2,
inputs, inputs,
.{false} ** 4, null,
true, true,
); );
return lerpAndOffsetAngles(raw); self.feedback_buffer.push(raw);
} }
fn drive(self: *const Controller, pos_error: AzEl) !AzEl { fn drive(self: *const Controller, pos_error: AzEl) !void {
// NOTE: feedback will be roughly config.controller.loop_interval_ns out of
// date. For high loop rates, this shouldn't be an issue.
const inputs = .{ config.controller.azimuth_input, config.controller.elevation_input };
var drive_signal: [4]bool = .{false} ** 4;
const azsign = signDeadzone( const azsign = signDeadzone(
pos_error.azimuth, pos_error.azimuth,
config.controller.angle_tolerance.azimuth, config.controller.angle_tolerance.azimuth,
@ -263,29 +321,35 @@ const Controller = struct {
config.controller.angle_tolerance.elevation, config.controller.angle_tolerance.elevation,
); );
var drive_signal: [4]bool = .{false} ** 4;
drive_signal[config.controller.azimuth_outputs.increase.io] = azsign == .positive; drive_signal[config.controller.azimuth_outputs.increase.io] = azsign == .positive;
drive_signal[config.controller.azimuth_outputs.decrease.io] = azsign == .negative; drive_signal[config.controller.azimuth_outputs.decrease.io] = azsign == .negative;
drive_signal[config.controller.elevation_outputs.increase.io] = elsign == .positive; drive_signal[config.controller.elevation_outputs.increase.io] = elsign == .positive;
drive_signal[config.controller.elevation_outputs.decrease.io] = elsign == .negative; drive_signal[config.controller.elevation_outputs.decrease.io] = elsign == .negative;
const raw = try self.labjack.readAnalogWriteDigital(2, inputs, drive_signal, true); const raw = self.feedback_buffer.getRaw();
const angles = lerpAndOffsetAngles(raw);
log.info( log.info(
// -180.1 is 6 chars. -5.20 is 5 chars // -180.1 is 6 chars. -5.20 is 5 chars
"az: {d: >6.1}° ({d: >5.2} V) Δ {d: >6.1}° => {u}, el: {d: >6.1}° ({d: >5.2} V) Δ {d: >6.1}° => {u}", "az: {d: >6.1}° ({d: >5.2} V) Δ {d: >6.1}° => {u}, el: {d: >6.1}° ({d: >5.2} V) Δ {d: >6.1}° => {u}",
.{ .{
angles.azimuth, self.position.azimuth,
raw[0].voltage, raw.azimuth,
pos_error.azimuth, pos_error.azimuth,
azsign.symbol(), azsign.symbol(),
angles.elevation, self.position.elevation,
raw[1].voltage, raw.elevation,
pos_error.elevation, pos_error.elevation,
elsign.symbol(), elsign.symbol(),
}, },
); );
return angles;
try self.labjack.writeIoLines(drive_signal);
}
fn setPosition(self: *Controller, position: AzEl) void {
self.position = position;
self.condition.broadcast();
} }
fn run(self: *Controller) !void { fn run(self: *Controller) !void {
@ -293,61 +357,60 @@ const Controller = struct {
var timer: LoopTimer = .{ .interval_ns = config.controller.loop_interval_ns }; var timer: LoopTimer = .{ .interval_ns = config.controller.loop_interval_ns };
while (timer.mark()) : (timer.sleep()) switch (self.current_state) { while (timer.mark()) : (timer.sleep()) {
.initializing, .idle => { self.updateFeedback() catch {
const pos = self.updateAzEl() catch {
self.lock.lock();
defer self.lock.unlock();
self.current_state = .stopped;
continue;
};
self.lock.lock(); self.lock.lock();
defer self.lock.unlock(); defer self.lock.unlock();
self.position = pos; self.current_state = .stopped;
self.current_state = self.requested_state; continue;
}, };
.calibration => {
{
self.lock.lock(); self.lock.lock();
defer self.lock.unlock(); defer self.lock.unlock();
// run calibration routine. psych, this does nothing. gottem self.setPosition(self.feedback_buffer.get());
self.current_state = .idle; }
self.requested_state = self.current_state;
}, switch (self.current_state) {
.running => { .initializing, .idle => {
const pos_error: AzEl = blk: { self.current_state = self.requested_state;
},
.calibration => {
self.lock.lock(); self.lock.lock();
defer self.lock.unlock(); defer self.lock.unlock();
break :blk .{ // run calibration routine. psych, this does nothing. gottem
.azimuth = self.target.azimuth - self.position.azimuth, self.current_state = .idle;
.elevation = self.target.elevation - self.position.elevation, self.requested_state = self.current_state;
},
.running => {
const pos_error: AzEl = blk: {
self.lock.lock();
defer self.lock.unlock();
break :blk .{
.azimuth = self.target.azimuth - self.position.azimuth,
.elevation = self.target.elevation - self.position.elevation,
};
}; };
};
const pos = self.drive(pos_error) catch { self.drive(pos_error) catch {
self.lock.lock(); self.lock.lock();
defer self.lock.unlock(); defer self.lock.unlock();
self.current_state = .stopped; self.current_state = .stopped;
continue; continue;
}; };
},
self.lock.lock(); .stopped => {
defer self.lock.unlock(); // attempt to reset the drive outputs
try self.labjack.writeIoLines(.{false} ** 4);
self.position = pos; break;
self.current_state = self.requested_state; },
}, }
.stopped => { }
// attempt to reset the drive outputs
_ = self.updateAzEl() catch {};
break;
},
};
} }
}; };

View File

@ -68,6 +68,30 @@ pub const Labjack = struct {
return status.toError(); return status.toError();
} }
pub fn writeIoLines(self: Labjack, out: [4]bool) LabjackError!void {
var id = self.cId();
var d_modes: c_long = 0xFF_FF;
var d_outputs: c_long = 0;
var d_states: c_long = 0;
const io_modes: c_long = 0b1111;
var io_outputs: c_long = PackedOutput.fromBoolArray(out).toCLong();
const status = c_api.DigitalIO(
&id,
self.demo(),
&d_modes,
io_modes,
&d_outputs,
&io_outputs,
1, // actually update the pin modes
&d_states,
);
if (!status.okay())
return status.toError();
}
/// Read one analog input channel, either single-ended or differential /// Read one analog input channel, either single-ended or differential
pub fn analogReadOne(self: Labjack, input: AnalogInput) LabjackError!AnalogReadResult { pub fn analogReadOne(self: Labjack, input: AnalogInput) LabjackError!AnalogReadResult {
if (!input.channel.isDifferential() and input.gain_index != 0) { if (!input.channel.isDifferential() and input.gain_index != 0) {