yaes/src/Config.zig
torque 61c10df63d
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.
2024-07-18 21:47:50 -07:00

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,
};
};