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.
190 lines
6.4 KiB
Zig
190 lines
6.4 KiB
Zig
const std = @import("std");
|
|
|
|
const AzEl = @import("./YaesuController.zig").AzEl;
|
|
const lj = @import("./labjack.zig");
|
|
|
|
const Config = @This();
|
|
|
|
var global_internal: std.json.Parsed(Config) = undefined;
|
|
pub const global: *const Config = &global_internal.value;
|
|
|
|
pub fn load(allocator: std.mem.Allocator, reader: anytype, err_writer: anytype) !void {
|
|
var jread = std.json.Reader(1024, @TypeOf(reader)).init(allocator, reader);
|
|
defer jread.deinit();
|
|
|
|
global_internal = try std.json.parseFromTokenSource(
|
|
Config,
|
|
allocator,
|
|
&jread,
|
|
.{},
|
|
);
|
|
|
|
try global.validate(err_writer);
|
|
}
|
|
|
|
pub fn loadDefault(allocator: std.mem.Allocator) void {
|
|
const arena = allocator.create(std.heap.ArenaAllocator) catch unreachable;
|
|
arena.* = std.heap.ArenaAllocator.init(allocator);
|
|
global_internal = .{
|
|
.arena = arena,
|
|
.value = .{},
|
|
};
|
|
}
|
|
|
|
pub fn deinit() void {
|
|
// TODO: implement this probably
|
|
const allocator = global_internal.arena.child_allocator;
|
|
global_internal.arena.deinit();
|
|
allocator.destroy(global_internal.arena);
|
|
}
|
|
|
|
pub fn validate(self: Config, err_writer: anytype) !void {
|
|
var valid: bool = true;
|
|
|
|
// zig fmt: off
|
|
if (
|
|
self.controller.parking_posture.azimuth < (
|
|
self.labjack.feedback_calibration.azimuth.minimum.angle
|
|
+ self.controller.angle_offset.azimuth
|
|
) or self.controller.parking_posture.azimuth > (
|
|
self.labjack.feedback_calibration.azimuth.maximum.angle
|
|
+ self.controller.angle_offset.azimuth
|
|
)
|
|
) {
|
|
// zig fmt: on
|
|
valid = false;
|
|
try err_writer.print(
|
|
"Config validation failed: Parking azimuth {d:.1} is outside of the valid azimuth range {d:.1} - {d:.1}\n",
|
|
.{
|
|
self.controller.parking_posture.azimuth,
|
|
self.labjack.feedback_calibration.azimuth.minimum.angle + self.controller.angle_offset.azimuth,
|
|
self.labjack.feedback_calibration.azimuth.maximum.angle + self.controller.angle_offset.azimuth,
|
|
},
|
|
);
|
|
}
|
|
|
|
// zig fmt: off
|
|
if (
|
|
self.controller.parking_posture.elevation < (
|
|
self.labjack.feedback_calibration.elevation.minimum.angle
|
|
+ self.controller.angle_offset.elevation
|
|
) or self.controller.parking_posture.elevation > (
|
|
self.labjack.feedback_calibration.elevation.maximum.angle
|
|
+ self.controller.angle_offset.elevation
|
|
)
|
|
) {
|
|
// zig fmt: on
|
|
valid = false;
|
|
try err_writer.print(
|
|
"Config validation failed: Parking elevation {d:.1} is outside of the valid elevation range {d:.1} - {d:.1}\n",
|
|
.{
|
|
self.controller.parking_posture.elevation,
|
|
self.labjack.feedback_calibration.elevation.minimum.angle + self.controller.angle_offset.elevation,
|
|
self.labjack.feedback_calibration.elevation.maximum.angle + self.controller.angle_offset.elevation,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (!valid)
|
|
return error.InvalidConfig;
|
|
}
|
|
|
|
rotctl: RotControlConfig = .{
|
|
.listen_address = "127.0.0.1",
|
|
.listen_port = 4533,
|
|
},
|
|
labjack: LabjackConfig = .{
|
|
.device = .autodetect,
|
|
.feedback_calibration = .{
|
|
// NOTE: these min and max angles are treated as hardware limits. This serves
|
|
// two purposes: first, it means that feedback is always interpolated,
|
|
// never extrapolated (though with a two point calibration, that doesn't
|
|
// matter much). Second, it prevents having a redundant set of bounds
|
|
// values that could potentially desync from these and cause problems.
|
|
//
|
|
// The functional min and max are these plus the angle offset values. For
|
|
// example, given controller.angle_offset.azimuth = -6, the practical minimum
|
|
// azimuth would be -6 deg and the practical maximum would be 444 deg.
|
|
.azimuth = .{
|
|
.minimum = .{ .voltage = 0.0, .angle = 0.0 },
|
|
.maximum = .{ .voltage = 5.0, .angle = 450.0 },
|
|
},
|
|
.elevation = .{
|
|
.minimum = .{ .voltage = 0.0, .angle = 0.0 },
|
|
.maximum = .{ .voltage = 5.0, .angle = 180.0 },
|
|
},
|
|
},
|
|
},
|
|
controller: ControllerConfig = .{
|
|
.azimuth_input = .{ .channel = .diff_01, .range = .@"5 V" },
|
|
.elevation_input = .{ .channel = .diff_23, .range = .@"5 V" },
|
|
.azimuth_outputs = .{ .increase = .{ .io = 0 }, .decrease = .{ .io = 1 } },
|
|
.elevation_outputs = .{ .increase = .{ .io = 2 }, .decrease = .{ .io = 3 } },
|
|
.loop_interval_ns = 100_000_000,
|
|
.parking_posture = .{ .azimuth = 180, .elevation = 90 },
|
|
.angle_tolerance = .{ .azimuth = 1, .elevation = 1 },
|
|
.angle_offset = .{ .azimuth = 0, .elevation = 0 },
|
|
// this is a symmetric mask, so the minimum usable elevation is elevation_mask deg
|
|
// and the maximum usable elevation is 180 - elevation_mask deg
|
|
.elevation_mask = 0.0,
|
|
.feedback_window_samples = 3,
|
|
},
|
|
|
|
pub const VoltAngle = struct { voltage: f64, angle: f64 };
|
|
pub const MinMax = struct {
|
|
minimum: VoltAngle,
|
|
maximum: VoltAngle,
|
|
|
|
pub inline fn slope(self: MinMax) f64 {
|
|
return self.angleDiff() / self.voltDiff();
|
|
}
|
|
|
|
pub inline fn voltDiff(self: MinMax) f64 {
|
|
return self.maximum.voltage - self.minimum.voltage;
|
|
}
|
|
|
|
pub inline fn angleDiff(self: MinMax) f64 {
|
|
return self.maximum.angle - self.minimum.angle;
|
|
}
|
|
};
|
|
|
|
const RotControlConfig = struct {
|
|
listen_address: []const u8,
|
|
listen_port: u16,
|
|
};
|
|
|
|
const LabjackConfig = struct {
|
|
device: union(enum) {
|
|
autodetect,
|
|
serial_number: i32,
|
|
},
|
|
// Very basic two-point calibration for each degree of freedom. All other angles are
|
|
// linearly interpolated from these two points. This assumes the feedback is linear,
|
|
// which seems to be a mostly reasonable assumption in practice.
|
|
feedback_calibration: struct {
|
|
azimuth: MinMax,
|
|
elevation: MinMax,
|
|
},
|
|
};
|
|
|
|
const ControllerConfig = struct {
|
|
azimuth_input: lj.AnalogInput,
|
|
elevation_input: lj.AnalogInput,
|
|
|
|
azimuth_outputs: OutPair,
|
|
elevation_outputs: OutPair,
|
|
|
|
loop_interval_ns: u64,
|
|
parking_posture: AzEl,
|
|
angle_tolerance: AzEl,
|
|
angle_offset: AzEl,
|
|
elevation_mask: f64,
|
|
|
|
feedback_window_samples: u8,
|
|
|
|
const OutPair = struct {
|
|
increase: lj.DigitalOutputChannel,
|
|
decrease: lj.DigitalOutputChannel,
|
|
};
|
|
};
|