Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/components/calibration-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/l
import { hex16, hexAddr } from "@/lib/format";
import { useSession } from "@/lib/session";
import { stampWords } from "@/lib/stamp";
import { calibrationStatus, type Calibration } from "@/lib/units";
import { calibrationStatus, type Calibration, type GearClass } from "@/lib/units";

const GEAR_CLASS_TEXT: Record<GearClass, string> = {
unknown: "Unknown",
plastic: "Plastic",
metal: "Metal",
};

export function CalibrationCard({ id, uid }: { id: number; uid: string }) {
const { descriptor, descriptorError, values, refreshConstants } = useSession();
Expand Down Expand Up @@ -79,6 +85,15 @@ export function CalibrationCard({ id, uid }: { id: number; uid: string }) {
onApply={apply}
/>
))}
{card !== undefined && (
<div role="group" aria-label="Gear train" className="flex flex-col gap-0.5">
<span className="text-xs text-text-3">Gear train</span>
<span className="text-base">{GEAR_CLASS_TEXT[card.constants.gearClass]}</span>
<span className="font-mono text-xs text-text-3">
gear_class {hexAddr(fieldNamed(fields, "gear_class").addr)}
</span>
</div>
)}
<div role="group" aria-label="Plant stamp" className="flex flex-col gap-0.5">
<span className="text-xs text-text-3">Plant stamp</span>
{stamp.error !== undefined ? (
Expand Down
6 changes: 4 additions & 2 deletions src/lib/bus/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ test("the live span is one read from pos through vmotor_bias_counts", () => {
expect(span(fields, LIVE_REGISTERS)).toEqual({ addr: 568, count: 56 });
});

test("the constants span is one read from raw_min through vmotor_bias_bot_ohm", () => {
expect(span(fields, CONSTANT_REGISTERS)).toEqual({ addr: 160, count: 46 });
test("the constants span is one read from raw_min through gear_class", () => {
expect(span(fields, CONSTANT_REGISTERS)).toEqual({ addr: 160, count: 49 });
});

test("plan fails loudly on a descriptor missing a register", () => {
Expand Down Expand Up @@ -181,6 +181,7 @@ test("the live, health and constants layouts decode from a whole-table image", (
put("angle_min_cdeg", -9500);
put("angle_max_cdeg", 9500);
put("gear_ratio_centi", 100);
put("gear_class", 2);
put("shunt_r_mohm", 10);
put("gain_milli", 32000);
put("vdd_mv", 3300);
Expand Down Expand Up @@ -210,6 +211,7 @@ test("the live, health and constants layouts decode from a whole-table image", (
});
expect(constants.sense.gainMilli).toBe(32000);
expect(constants.calibrated.valid).toBe(true);
expect(constants.gearClass).toBe("metal");
expect(liveFrom(read(LIVE_REGISTERS))).toEqual({
pos: 2054,
current: 2100,
Expand Down
13 changes: 11 additions & 2 deletions src/lib/bus/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
biasesFromTable,
calibrationFromTable,
calibrationStatus,
gearClassFromTable,
senseFromTable,
type Biases,
type Calibration,
type CalibrationStatus,
type GearClass,
type ReadRegister,
type Sense,
} from "../units";
Expand Down Expand Up @@ -128,7 +130,7 @@ export function decodeSpan(fields: readonly Field[], span: Span, bytes: Uint8Arr
};
}

/** CALIB pot, kinematics, sense and sense_ext: read once per servo. */
/** CALIB pot, kinematics, sense, sense_ext and gear class: read once per servo. */
export const CONSTANT_REGISTERS: readonly string[] = [
"raw_min",
"raw_max",
Expand All @@ -148,6 +150,7 @@ export const CONSTANT_REGISTERS: readonly string[] = [
"vmotor_bias_nom_counts",
"vmotor_bias_top_ohm",
"vmotor_bias_bot_ohm",
"gear_class",
];

/** TELEMETRY sensors the cards show, plus the biases their conversion needs. */
Expand Down Expand Up @@ -190,11 +193,17 @@ export interface Constants {
sense: Sense;
calibration: Calibration;
calibrated: CalibrationStatus;
gearClass: GearClass;
}

export function constantsFrom(read: ReadRegister): Constants {
const calibration = calibrationFromTable(read);
return { sense: senseFromTable(read), calibration, calibrated: calibrationStatus(calibration) };
return {
sense: senseFromTable(read),
calibration,
calibrated: calibrationStatus(calibration),
gearClass: gearClassFromTable(read),
};
}

export interface Live {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fixture from "../../tests/fixtures/stall-24mhz.json";
import {
clampGoal,
CONTROL_REGISTERS,
displayEnds,
goalAtStop,
decodeControl,
dutyPercent,
Expand Down Expand Up @@ -157,6 +158,21 @@ test("the position goal is degrees through the calibration, or counts in raw mod
expect(raw.fromDisplay(1234.6)).toBe(1235);
});

test("a descending angle map reads its goal ends lowest degree first, the slider inverted", () => {
// degrees counterclockwise-positive on a pot whose counts rise clockwise
const ccwLow: Calibration = { ...cal, angleMinCdeg: 18000, angleMaxCdeg: 0 };
const deg = goalSpec("Position", { ...ctx, cal: ccwLow });
expect(deg.range).toEqual({ min: 200, max: 3800 });
expect(deg.fromDisplay(135)).toBe(1100);
expect(displayEnds(deg)).toEqual({ low: 3800, high: 200, inverted: true });
expect(displayEnds(goalSpec("Position", ctx))).toEqual({ low: 200, high: 3800, inverted: false });
const vel = goalSpec("Velocity", { ...ctx, cal: ccwLow });
expect(vel.fromDisplay(50)).toBe(-1000);
expect(displayEnds(vel)).toEqual({ low: 4000, high: -4000, inverted: true });
const raw = goalSpec("Position", { ...ctx, cal: ccwLow, raw: true });
expect(displayEnds(raw).inverted).toBe(false);
});

test("the velocity goal is deg/s over the velocity limit, counts/s in raw mode", () => {
const spec = goalSpec("Velocity", ctx);
expect(spec).toMatchObject({ register: "goal_velocity", unit: "deg/s", digits: 0 });
Expand Down
11 changes: 11 additions & 0 deletions src/lib/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,14 @@ export interface GoalSpec extends GoalUnits {
export function goalSpec(mode: ModeName, ctx: GoalContext): GoalSpec {
return { ...goalUnits(mode, ctx), range: goalRange(mode, ctx.cal, ctx.limits) };
}

/**
* The goal range's ends as the display reads them, lowest first. Degrees rise
* counterclockwise, so on a pot whose counts rise clockwise the angle map
* descends and the ends swap: `inverted` turns the slider to match.
*/
export function displayEnds(spec: GoalSpec): { low: number; high: number; inverted: boolean } {
const { min, max } = spec.range;
const inverted = spec.toDisplay(max) < spec.toDisplay(min);
return inverted ? { low: max, high: min, inverted } : { low: min, high: max, inverted };
}
6 changes: 6 additions & 0 deletions src/lib/table-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ describe("real units", () => {
expect(realValue(rowOf("accel_limit_q88"), 3840, cal)).toBe("1500 deg/s^2");
});

test("a descending angle map leaves the trajectory limits unsigned", () => {
const ccwLow = { ...cal, angleMinCdeg: 18000, angleMaxCdeg: 0 };
expect(realValue(rowOf("velocity_limit_cps"), 1500, ccwLow)).toBe("75 deg/s");
expect(realValue(rowOf("accel_limit_q88"), 3840, ccwLow)).toBe("1500 deg/s^2");
});

test("without a usable calibration, or for any other register, the raw value stands alone", () => {
expect(realValue(rowOf("velocity_limit_cps"), 1500, undefined)).toBeUndefined();
expect(realValue(rowOf("velocity_limit_cps"), 1500, { ...cal, rawMax: 100 })).toBeUndefined();
Expand Down
11 changes: 9 additions & 2 deletions src/lib/table-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,15 @@ const TEXT: ReadonlyMap<string, (value: number) => string> = new Map([

/** Registers that read in real units through a usable calibration; the raw value stays beside them. */
const REAL: ReadonlyMap<string, (counts: number, cal: Calibration) => string> = new Map([
["velocity_limit_cps", (v, cal) => formatQuantity(velocityDegPerS(v, cal), DISPLAY.velocity)],
["accel_limit_q88", (v, cal) => formatQuantity(accelDegPerS2(v, cal), DISPLAY.acceleration)],
// limits are magnitudes: a descending angle map must not sign them
[
"velocity_limit_cps",
(v, cal) => formatQuantity(Math.abs(velocityDegPerS(v, cal)), DISPLAY.velocity),
],
[
"accel_limit_q88",
(v, cal) => formatQuantity(Math.abs(accelDegPerS2(v, cal)), DISPLAY.acceleration),
],
]);

export function realValue(
Expand Down
6 changes: 6 additions & 0 deletions src/lib/units.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
currentMa,
degPerCount,
degToCounts,
gearClassFromTable,
motorV,
positionDeg,
senseFromTable,
Expand Down Expand Up @@ -123,6 +124,11 @@ test("calibrationStatus rejects an unset gear ratio", () => {
expect(got.reason).toMatch(/gear/);
});

test("gear_class names plastic and metal and reads anything else as unknown", () => {
const named = [0, 1, 2, 3, 255].map((code) => gearClassFromTable(readFrom({ gear_class: code })));
expect(named).toEqual(["unknown", "plastic", "metal", "unknown", "unknown"]);
});

test("positionDeg maps the calibrated span onto the angle endpoints", () => {
expect(positionDeg(CAL.rawMin, CAL)).toBeCloseTo(-95, 10);
expect(positionDeg(CAL.rawMax, CAL)).toBeCloseTo(95, 10);
Expand Down
8 changes: 8 additions & 0 deletions src/lib/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export interface Calibration {
gearRatioCenti: number;
}

/** `gear_class`, which cal writes; 0 or a code this build does not know is unknown. */
export type GearClass = "unknown" | "plastic" | "metal";

export function gearClassFromTable(read: ReadRegister): GearClass {
const code = read("gear_class");
return code === 1 ? "plastic" : code === 2 ? "metal" : "unknown";
}

/** Bias counts the servo measures for itself; both live in TELEMETRY, not CALIB. */
export interface Biases {
currentBiasCounts: number;
Expand Down
11 changes: 7 additions & 4 deletions src/routes/live.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
dutyPercent,
goalOf,
goalAtStop,
displayEnds,
goalSpec,
goalUnits,
isWindow,
Expand Down Expand Up @@ -675,6 +676,7 @@ function Controls({
};

const fmt = (c: number) => (spec === undefined ? "" : spec.toDisplay(c).toFixed(spec.digits));
const ends = spec === undefined ? { low: 0, high: 0, inverted: false } : displayEnds(spec);
// A latched fault holds the motor off whatever torque_enable says
// (firmware kernel/faults.rs); the switch alone does not say so.
const faulted = servo?.fault !== undefined;
Expand Down Expand Up @@ -758,15 +760,16 @@ function Controls({
aria-label="Goal"
min={spec.range.min}
max={spec.range.max}
inverted={ends.inverted}
step={1}
value={[counts]}
onValueChange={([v]) => {
if (v !== undefined) setGoal(v);
}}
/>
<div className="flex justify-between text-xs text-text-3 tabular-nums">
<span>{fmt(spec.range.min)}</span>
<span>{fmt(spec.range.max)}</span>
<span>{fmt(ends.low)}</span>
<span>{fmt(ends.high)}</span>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={goalId} className="w-14">
Expand All @@ -775,8 +778,8 @@ function Controls({
<GoalInput
id={goalId}
value={fmt(counts)}
min={fmt(spec.range.min)}
max={fmt(spec.range.max)}
min={fmt(ends.low)}
max={fmt(ends.high)}
step={10 ** -spec.digits}
unit={spec.unit}
onCommit={(v) => {
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/calibration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ test("the card shows the status chip and one row per calibration register", asyn
await expect(group.getByRole("button", { name: value })).toBeVisible();
}
await expect(card.getByText("raw_max 0x0a2")).toBeVisible();
const gears = card.getByRole("group", { name: "Gear train" });
await expect(gears).toContainText("Plastic");
await expect(gears).toContainText("gear_class 0x0d0");
});

test("edits write through, the row shows the new value and the status stays Calibrated", async ({
Expand Down
Loading