Skip to content

Units & formatting

@rogue/console-sdk/units is a separate import path holding pure functions: pace/distance/power/calorie conversions and display formatting. It carries zero React and zero React Native imports, so it runs anywhere — a server, a non-RN script, a test — and it is fully tree-shakeable: import one function and a bundler drops the rest.

Its defining property is firmware parity. These functions deliberately reproduce the console firmware’s own math, including its truncation quirks and its rounded constants, so a value you compute or format matches what the physical console screen shows to the digit. Parity is a correctness requirement here, not an approximation — a coach comparing your app to the console must see the same number.

The workhorse is formatPace. The SDK’s canonical pace is always seconds-per-500m (that is what LiveMetrics.pace.current and every result carry); formatPace converts to the requested unit and renders the console’s M:SS / M:SS.t string:

import { formatPace } from "@rogue/console-sdk/units";
formatPace(135); // "2:15.0" — default per500m, tenths shown
formatPace(135, "per1000m"); // "4:30" — whole seconds
formatPace(135, "perMile"); // firmware-parity /mile rendering
formatPace(0); // "--:--" — a zero/absent pace

The whole-seconds display truncates (floors) rather than rounds, exactly like the console. Tenths are shown only for per500m; the derived per1000m and perMile renderings show whole seconds, mirroring how the console labels those units.

formatDuration is the plain clock string that pairs with formatPace for workout headers, elapsed timers and split rows — M:SS, or H:MM:SS once it reaches an hour. Unlike pace it carries no firmware-parity quirk (it’s plain clock arithmetic), so it’s locale-neutral and floors fractional/negative input.

import { formatDuration } from "@rogue/console-sdk/units";
formatDuration(65); // "1:05"
formatDuration(3661); // "1:01:01"
formatDuration(0); // "0:00"

For a smoothly ticking elapsed header, don’t render a session field on a timer — derive it: repaint on your own ~500 ms interval and compute formatDuration(elapsedSecondsAt(session.timing, Date.now())). The SDK exposes elapsed as a value (elapsedSecondsAt), never a per-second stream, so the tick cadence is yours to choose.

The PaceUnit argument is the SDK’s own DisplayUnits['pace'] union, so a device’s current display setting flows straight through — no free strings, no lookup table:

import { formatPace } from "@rogue/console-sdk/units";
import { useLiveMetrics } from "@rogue/console-sdk";
import type { Device } from "@rogue/console-sdk";
function PaceTile({ device }: { device: Device }) {
const pace500 = useLiveMetrics(device.id, (m) => m.pace.current); // seconds/500m | null
return <Big>{formatPace(pace500 ?? 0, device.displayUnits.pace)}</Big>;
}

device.displayUnits.pace is set by useConsoleControls().setDisplayUnits; pass it to formatPace and the rendered pace tracks whatever unit the console is showing.

Every conversion is a plain function with the firmware quirk baked in where one exists:

Function Converts Quirk preserved
metersToMiles metres → miles +0.0005 display bias, then two-decimal truncation
metersToKilometers metres → km none (plain ratio)
pace500ToMile s/500m → s/mile integer-millisecond truncation, twice
pace500To1000m s/500m → s/1000m none (exactly double)
computePace500 distance + time → s/500m 0 when undefined
computePace1000 distance + time → s/1000m 0 when undefined
import { metersToMiles, pace500ToMile } from "@rogue/console-sdk/units";
metersToMiles(1609.344); // 1
pace500ToMile(120); // firmware-parity seconds per mile at a 2:00/500m pace

Power, calories, and cadence — modality-branching

Section titled “Power, calories, and cadence — modality-branching”

The derived-metric math branches exactly as the firmware does. Bikes compute power and calories from a calorie counter and a watt-tiered inefficiency factor; rowers, skiers, and rhino (the pace-driven machines) derive power from the 2.8·v³ pace curve. An unknown/null modality falls back to the pace-driven path, matching the firmware default.

You rarely call the individual formulas — computeRecordSplitMetrics does the branching for you from a raw split record:

import { computeRecordSplitMetrics } from "@rogue/console-sdk/units";
const derived = computeRecordSplitMetrics({
distanceMeters: 500,
durationSeconds: 120,
strokeCount: 48,
modality: "rower", // omitted/null → pace-driven path
calories: 42, // only used on the bike path
});
// derived: { pace500, avgPower, avgCalPerHour, spm }

isBikeModality(modality) is the predicate behind the branch if you need it directly. The cadence helpers (computeSpm, computeBikeRpm) reproduce the firmware’s decisecond / 100 ms duration truncation, so a stroke rate matches the console even mid-second.

Firmware-parity doctrine: the deliberate quirks

Section titled “Firmware-parity doctrine: the deliberate quirks”

The constants and truncations are specification, not bugs to fix. Two examples that look wrong until you know why:

  • MILE_TO_KM_FACTOR = 0.6215 is the firmware’s rounded float baked into app_motion_pace1000m_to_MILE(). The mathematically exact value is 0.62137…, and using it would drift the app’s per-mile pace away from the console screen. The SDK ships the firmware’s number on purpose.
  • metersToMiles adds +0.0005 then truncates to two decimals — the firmware’s (uint32_t)(mile * 100) / 100.0f display cast. Rounding “cleanly” would show a different distance than the console.

Every such constant is exported and documented as spec, so the doctrine is visible rather than hidden inside a helper: MILE_TO_KM_FACTOR, METERS_PER_MILE, ROWER_POWER_COEFFICIENT, BIKE_CAL_BASE_COEFFICIENT, and the calorie/BMR constants. If you ever need to explain why the app and the console agree to the third decimal, this is the answer: they run the same math.

/units is isolated behind its own import path for two reasons. First, purity: with no React or RN in its dependency graph, the same firmware-parity math runs in a Node script that post-processes a WorkoutResult, a server that renders a summary, or a test — none of which can (or should) load the RN transport. Second, tree-shaking: nothing from /units is re-exported at the SDK root, so a UI that only needs formatPace pulls in formatPace and its transitive constants, and nothing else.