src/lib/dive-gas.ts
Rama main · réplica de lectura del repositorio original.
import type {
Dive,
DiveGasUse,
DiveMixture,
DiveTankGasUse,
GasRole,
} from "@/lib/api/dives";
import {
type ChartScope,
periodLabel,
periodRange,
stepPeriod,
} from "@/lib/chart-period";
import { formatDurationHoursMinutes } from "@/lib/date-time";
import { gasName, hasStagedCylinder, isParallelSet } from "@/lib/dive-mixtures";
import { primaryRecording } from "@/lib/dive-recordings";
// A value `p` of the way through `values` (0 is the lowest, 1 the highest),
// interpolating linearly between the two it falls between - R's default and the
// one spreadsheets use, so a figure here matches one a diver works out by hand.
// Sorts its own copy; callers pass raw series.
export function quantile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b);
const position = (sorted.length - 1) * p;
const below = Math.floor(position);
const above = Math.min(sorted.length - 1, below + 1);
return sorted[below] + (sorted[above] - sorted[below]) * (position - below);
}
// Median and the middle half, which is what the chart draws as a box: the
// quartiles describe where the bulk of a set of dives sits, and unlike a mean and
// a standard deviation they stay put when one dive comes in at twice everyone
// else's rate - a misread gauge, or a swim against current, moves a quartile by
// at most one rank.
export interface Quartiles {
q1: number;
median: number;
q3: number;
}
export function quartiles(values: number[]): Quartiles {
return {
q1: quantile(values, 0.25),
median: quantile(values, 0.5),
q3: quantile(values, 0.75),
};
}
// The calendar units the chart summarizes dives by, finest first.
export const BIN_UNITS = ["day", "week", "month", "quarter", "year"] as const;
export type BinUnit = (typeof BIN_UNITS)[number];
// How long each unit runs, near enough to size it on an axis; the bins
// themselves follow the calendar.
const DAY_MS = 24 * 60 * 60 * 1000;
const BIN_UNIT_MS: Record<BinUnit, number> = {
day: DAY_MS,
week: 7 * DAY_MS,
month: 30.44 * DAY_MS,
quarter: 91.31 * DAY_MS,
year: 365.25 * DAY_MS,
};
// The narrowest a bin may be, in viewBox units, before the chart steps up to
// the next unit. Wide enough for a box with a median tick inside it to read as
// one, and just under the design width's room for a week in a year (about 700
// units over 52 weeks), so a year is read in weeks on a desktop and in months on a
// phone.
export const BIN_MIN_WIDTH = 12;
// The finest unit whose bins get at least `BIN_MIN_WIDTH` of a `plotWidth`-wide
// axis spanning `rangeMs`.
//
// The unit is chosen by room on the axis rather than by the calendar or by the
// gaps between dives, and that is the whole design. Each fixed choice fails
// somewhere: one calendar unit for every scope is a month of nothing but a
// single box or a career of three hundred slivers; dives grouped by the breaks
// between them turn a weekend diver's career into a hundred and fifty
// overlapping marks and an instructor's season into one box with one median;
// and a fixed trailing window of dives makes ten turns inside one slot of a
// year's axis and then runs flat for months. Sized to the axis, a week-long
// trip is one box in a year and seven in a month, a season is twenty-six boxes
// in a year and six in a career, and the gaps between trips are simply empty
// bins. It is how a candlestick chart moves from daily to weekly to monthly
// candles as it zooms out.
export function binUnit(rangeMs: number, plotWidth: number): BinUnit {
return (
BIN_UNITS.find(
(unit) => (plotWidth * BIN_UNIT_MS[unit]) / rangeMs >= BIN_MIN_WIDTH,
) ?? "year"
);
}
// The start of the bin holding `time`, in the dive's own wall-clock calendar -
// the chart's timestamps are read back with `getUTC*` only (`diveWallClockTime`).
// Weeks start on Monday.
export function binStart(time: number, unit: BinUnit): number {
const date = new Date(time);
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();
switch (unit) {
case "day":
return Date.UTC(year, month, day);
case "week":
return Date.UTC(year, month, day - ((date.getUTCDay() + 6) % 7));
case "month":
return Date.UTC(year, month, 1);
case "quarter":
return Date.UTC(year, month - (month % 3), 1);
case "year":
return Date.UTC(year, 0, 1);
}
}
// The start of the bin after the one starting at `start`.
export function binNext(start: number, unit: BinUnit): number {
const date = new Date(start);
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();
switch (unit) {
case "day":
return Date.UTC(year, month, day + 1);
case "week":
return Date.UTC(year, month, day + 7);
case "month":
return Date.UTC(year, month + 1, 1);
case "quarter":
return Date.UTC(year, month + 3, 1);
case "year":
return Date.UTC(year + 1, 0, 1);
}
}
// One bin with dives in it: its calendar bounds (`[start, end)`), when its
// first and last dive were, how many, and the quartiles of their RMVs.
export interface BinSummary extends Quartiles {
start: number;
end: number;
first: number;
last: number;
count: number;
}
// The series summarized per `unit`, in order, with empty bins left out.
// `times` sorted ascending, as the API returns them, with `values` in step.
export function binSummaries(
times: number[],
values: number[],
unit: BinUnit,
): BinSummary[] {
const bins: { start: number; indices: number[] }[] = [];
times.forEach((time, index) => {
const start = binStart(time, unit);
const current = bins[bins.length - 1];
if (current?.start === start) {
current.indices.push(index);
} else {
bins.push({ start, indices: [index] });
}
});
return bins.map(({ start, indices }) => ({
start,
end: binNext(start, unit),
first: times[indices[0]],
last: times[indices[indices.length - 1]],
count: indices.length,
...quartiles(indices.map((index) => values[index])),
}));
}
// The fraction of the series an axis is allowed to leave off each end, so one
// misread gauge at twice everyone else's rate does not set the top of the axis
// and squash the other two hundred dives into the bottom third of the plot.
// Dropping one dive in a hundred from each end only ever touches a series of a
// hundred or more - a shorter one keeps its extremes - and the chart pins what
// it drops to the edge rather than hiding it.
export const DOMAIN_CLIP = 0.01;
// The values an axis should be scaled to: the series with its outliers, by the
// rule above, left out. Whole ranks rather than an interpolated percentile, so
// a series too short to lose a dive loses none of its range either.
export function clipOutliers(values: number[]): number[] {
const drop = Math.floor(values.length * DOMAIN_CLIP);
if (drop === 0) return values;
const sorted = [...values].sort((a, b) => a - b);
return sorted.slice(drop, sorted.length - drop);
}
// The three marks sharing the gas chart's plot, each of which the diver can turn
// off. A bin's box and its median tick are one mark, not two: the tick is the
// figure and the box says how tightly the dives behind it were clustered, so a
// chart with one and not the other says less than either alone. Likewise the
// baseline's median line and its middle-half band.
export const GAS_USE_MARKS = ["dives", "trend", "average"] as const;
export type GasUseMark = (typeof GAS_USE_MARKS)[number];
// The half-open `[start, end)` bounds the chart actually plots, for any scope.
//
// "year"/"month" are just `periodRange`. "all" is the whole series, and the
// subtlety is entirely in its `end`: callers filter with `time < end`, so an
// `end` of exactly the last dive's timestamp drops that dive - always the
// newest one, and every dive tied with it. `+1` is enough to make the bound
// inclusive of the last sample while keeping the half-open convention that
// `periodRange` and every caller already rely on.
//
// `times` must be sorted ascending and non-empty; an empty series has no range
// to speak of and returns a degenerate `[0, 1)` rather than `NaN` bounds.
export function scopeRange(
times: number[],
anchor: number,
scope: ChartScope,
): { start: number; end: number } {
if (scope !== "all") return periodRange(anchor, scope);
if (times.length === 0) return { start: 0, end: 1 };
return { start: times[0], end: times[times.length - 1] + 1 };
}
// The sub-periods to shade behind the plot, so alternate months (within a year)
// or alternate years (across a career) read as separate blocks.
//
// This is the chart's only vertical structure. Without it the x labels float
// under a plot whose horizontal position means nothing in particular, and a
// cluster of dots is impossible to place in the year without tracing down to the
// axis. Shading is deliberately the mechanism rather than gridlines: a diving
// season is an *interval*, not an instant, so a band is the honest mark for it -
// and a chart that already carries dots, the bins and the baseline's band and
// line does not need twelve more lines drawn through it.
//
// A month is too short to subdivide usefully, so it gets none. Everything is
// clamped to the plotted range, which matters for "all" - a career starts
// partway through its first year and ends partway through its last.
export function bandRanges(
scope: ChartScope,
range: { start: number; end: number },
): { start: number; end: number }[] {
if (scope === "month") return [];
const bands: { start: number; end: number }[] = [];
if (scope === "year") {
const year = new Date(range.start).getUTCFullYear();
// February, April, ... - the first month unshaded, so January reads against
// the card rather than against a band.
for (let month = 1; month < 12; month += 2) {
bands.push({
start: Date.UTC(year, month, 1),
end: Date.UTC(year, month + 1, 1),
});
}
} else {
const firstYear = new Date(range.start).getUTCFullYear();
const lastYear = new Date(range.end).getUTCFullYear();
for (let year = firstYear; year <= lastYear; year++) {
if ((year - firstYear) % 2 === 1) {
bands.push({
start: Date.UTC(year, 0, 1),
end: Date.UTC(year + 1, 0, 1),
});
}
}
}
return bands
.map((band) => ({
start: Math.max(band.start, range.start),
end: Math.min(band.end, range.end),
}))
.filter((band) => band.end > band.start);
}
export interface GasUseSummary {
// How many dives the figures below are drawn from.
dives: number;
// The period's median RMV. A median rather than a mean so the headline is
// judged against the chart's all-time median baseline like for like, and so
// one misread gauge in a period moves it by at most a rank.
median: number;
// Lowest RMV in the period. Lower is better, so this is the best dive, not an
// outlier to be explained away.
best: number;
// Change in median against the previous period *with dives*, as a percentage
// of that period's median - negative being an improvement. Null when there
// is nothing to compare against: the "all" scope, or the earliest period.
changePercent: number | null;
// What that previous period is called, for the label that carries the number.
// Null exactly when `changePercent` is.
previousLabel: string | null;
}
// The figures above the chart: this period's median, the best dive in it, and
// whether that median is going the right way.
//
// The chart shows the shape; this answers "am I improving", which is the
// question the card exists for and the one a scatter of dots is worst at. It
// compares against the previous period *containing dives* rather than the
// previous calendar one, for the same reason the arrows step that way (see
// `stepPeriod`): diving happens in bursts, and "vs last month" is meaningless
// when last month was the off-season.
//
// Returns null when the period holds no dives, which the "all" scope can only
// hit on an empty series.
export function summarizeGasUse(
times: number[],
rmvs: number[],
scope: ChartScope,
anchor: number,
): GasUseSummary | null {
const inPeriod = (range: { start: number; end: number }) =>
rmvs.filter(
(_, index) => times[index] >= range.start && times[index] < range.end,
);
const current = scope === "all" ? rmvs : inPeriod(periodRange(anchor, scope));
if (current.length === 0) return null;
const median = quantile(current, 0.5);
const summary: GasUseSummary = {
dives: current.length,
median,
best: Math.min(...current),
changePercent: null,
previousLabel: null,
};
if (scope === "all") return summary;
const previousAnchor = stepPeriod(anchor, scope, -1, times);
if (previousAnchor === null) return summary;
const previousRange = periodRange(previousAnchor, scope);
const previous = inPeriod(previousRange);
// `stepPeriod` returned a dive's own timestamp, so the period it sits in has
// at least that one dive and this cannot divide by zero.
const previousMedian = quantile(previous, 0.5);
return {
...summary,
changePercent: ((median - previousMedian) / previousMedian) * 100,
previousLabel: periodLabel(previousRange.start, scope),
};
}
// How long a break in diving has to be before the line joining one bin's median
// to the next is drawn dashed across it. Two months is comfortably longer than
// the gap between dives on a trip, or between trips in an active season, and
// comfortably shorter than an off-season.
//
// A solid connector across that gap is a lie - a week in April and a week in
// October joined by a long segment reads as "my consumption was steady all
// summer" when the truth is "there is no data here". Dashed, it still carries
// the eye from one trip's figure to the next without claiming the months
// between.
export const TREND_GAP_MS = 60 * 24 * 60 * 60 * 1000;
// Total pressure drop across a set of cylinders, in bar, with a row that is missing
// either pressure contributing nothing.
//
// **Both callers have already established that every cylinder has both pressures**
// before they ask - the flagged-parallel block refuses the set outright above, and the
// nudge tests for it in the `&&` that short-circuits this call - so the skip never
// actually fires. It is here because the types cannot carry that narrowing across a
// function boundary, and skipping beats `?? 0`, which would silently read a missing
// pressure as a full cylinder if a third caller ever did ask.
//
// Mirrors `compute_parallel_gas_use`'s `sum(drops) <= 0` refusal, which is the one
// guard where the *total* matters and a single zero-drop row does not: a carried-but-
// untouched cylinder contributes zero litres and the dive still has a figure.
function totalPressureDrop(mixtures: readonly DiveMixture[]): number {
return mixtures.reduce(
(sum, mixture) =>
mixture.start_pressure != null && mixture.end_pressure != null
? sum + (mixture.start_pressure - mixture.end_pressure)
: sum,
0,
);
}
// Why a dive has no `gas_use`, phrased for the user, or `null` when there's
// nothing worth saying (the figure is present, or the dive logs no tank at all
// and so was never a candidate).
//
// The API returns `gas_use: null` for every un-derivable dive without saying
// why - correctly, since the reasons are a UI concern and the conditions are
// all plainly visible in the dive itself. This is the counterpart that turns
// that silence into something actionable: a diver who filled in pressures and
// still sees no number needs to know it's the missing average depth, not a bug.
// Deliberate mirror of the guard clauses in the API's `compute_gas_use()`,
// `compute_multi_tank_gas_use()` *and* `compute_parallel_gas_use()` - if any of
// the three functions' conditions change, this list has to change with it (one
// `grep gas_use` finds the set). The parallel one carries a guard the others do not:
// `avg_depth` is an input to its *multi-cylinder* derivation.
//
// `volume` is nullable, so a cylinder can record a gas and no vessel, and each
// function refuses one at its own point in its own order. Each branch below
// therefore carries a size sentence in the position its function checks it - after
// the pressures for one cylinder, before them for a flagged pair, and instead of the
// attribution sentence where the attributed cylinders are the unsized ones.
//
// Only meaningful on a dive from the *detail* endpoint: the list response
// carries neither `mixtures` nor `gas_use`, so every dive in it would look
// un-derivable. Guarded rather than assumed, since both fields are typed as
// present on the shared `Dive` interface.
export function gasUseUnavailableReason(dive: Dive): string | null {
if (dive.gas_use) return null;
const mixtures = dive.mixtures ?? [];
if (mixtures.length === 0) return null;
if (mixtures.length > 1) {
// **The flagged-parallel block comes first, and its position is load-bearing.**
// A set the diver has flagged `parallel` throughout is summed by
// `compute_parallel_gas_use` against the dive's own submerged time and average
// depth, with no profile and no attribution anywhere in it - so every
// attribution sentence below is false of it, and the no-profile branch that
// fires before any of them would tell a diver who needs an average depth to
// go and import a dive-computer file. The reasons here are the additive
// branch's own guards, in its order, with the two-missing case asked first.
//
// That combined ask is not tidiness. The new-dive form carries `usage` over
// from the last dive, so all-parallel-with-nothing-else-filled-in is the
// *opening* state of a sidemount diver's every subsequent dive; asking for
// the depth and then, once it is typed, for the pressures would make the
// commonest state of this form a sequence of refusals. Same shape and same
// reason as the single-cylinder combined ask further down.
if (isParallelSet(mixtures)) {
const missingDepth = dive.avg_depth == null || dive.avg_depth <= 0;
const missingPressures = mixtures.some(
(mixture) =>
mixture.start_pressure == null || mixture.end_pressure == null,
);
if (missingDepth && missingPressures) {
return "Add an average depth and every cylinder's start and end pressure to see your gas consumption.";
}
// Singular and specifically not max depth, exactly as the one-cylinder
// branch has it: a dive spends a moment at its deepest point, so using it
// would overstate the ambient pressure the litres are normalized against.
// This is the first time `avg_depth` is asked for on a multi-cylinder dive
// at all - see the comment on the plural pressures ask below, which says
// why the attribution path must never ask for it.
if (missingDepth) {
return "Add an average depth to see your gas consumption.";
}
// In `compute_parallel_gas_use`'s own position, which puts it *above* the
// pressures rather than below them as the single-cylinder branch has it. The
// two orders are the two functions' orders, and neither is arbitrary: a
// one-cylinder dive is refused for its size only once the gauges are in,
// while the additive path refuses an unsized cylinder before it looks at any
// pressure at all.
//
// *Every* cylinder, on the same terms as the pressures below: one cylinder
// with no recorded size contributes no litres to a sum whose denominator
// still covers the whole dive, which reports an RMV too low rather than none
// at all. Same wording as the pressure ask beside it, because it is the same
// refusal about a different input.
if (mixtures.some((mixture) => mixture.volume == null)) {
return "Add every cylinder's size to see your gas consumption.";
}
// *Every* cylinder, not "each": one missing pressure anywhere refuses the
// whole set. A pair whose second cylinder is bare cannot be summed
// honestly - its litres would be missing from the numerator while the
// whole dive stayed in the denominator, reporting an RMV that is too low.
if (missingPressures) {
return "Add every cylinder's start and end pressure to see your gas consumption.";
}
// Pressures on every cylinder, none of them showing a drop. A zero-drop row
// on its own is fine - a carried-but-untouched cylinder contributes zero
// litres and the API sums it happily - so this is only the case where the
// *total* is zero. "Add up", not "divide up": nothing is being apportioned
// on this path.
if (totalPressureDrop(mixtures) <= 0) {
return "No cylinder on this dive records a drop between its start and end pressure, so there is no gas used to add up.";
}
// Everything the additive branch needs is present, so the API has a figure
// and this function was not called for this dive. Nothing is left to
// explain, and a sentence written for a state the UI cannot produce would
// be untestable wording - the same rule this branch already applies to the
// duplicate-`gas_number` refusal below. Falling through instead would hand
// a flagged pair the no-profile message, which is the one thing this block
// exists to prevent.
return null;
}
// **The nudge**, and it precedes the no-profile branch deliberately. This is
// where a sidemount diver finds out the flag exists, and 18 of the 19
// multi-gas dives in the corpus are hand-logged with no profile and no source
// file at all - so ordered after that branch it would never render for the
// population it is for.
//
// Ordered before it *ungated* it would capture that whole population, whose
// commonest shape is back gas plus a staged deco bottle with no bottle
// pressures, and re-create the tell-them-one-thing-then-refuse-again pattern
// the no-profile branch was placed first to stop. So two gates:
//
// 1. **Every cylinder carries both pressures, and the total drop is
// positive.** This screens for the staged-bottle *shape* - a bottle with
// no pressures is that population's tell - and not for full
// rescuability. The additive branch also wants an average depth, and that
// is deliberately left out: a missing depth says nothing about whether a
// pair was breathed in parallel, and widening the gate with it would drop
// that diver onto the no-profile message instead, with no path to the
// figure and no discovery of the flag. A pair with pressures and no depth
// is therefore nudged, flags Parallel, and meets the missing-depth ask
// above as one follow-up. Both steps are actionable and the second ends in
// the figure, which is what separates it from the recorded anti-pattern -
// that one ends in a refusal.
// 2. **No cylinder flagged `staged`.** A parallel pair plus an explicitly
// staged bottle is refused by design, and a diver who used the control
// exactly right must not be nudged on every render. The `staged` flag is
// that set's tell, so the gate keys on it and nothing wider: suppressing
// on *any* explicit `usage` would also silence the half-flagged pair - one
// row Parallel, one still Not recorded - which is the likeliest path into
// the feature, and drop that diver onto "import a dive-computer file". A
// half-flagged pair keeps the nudge, whose condition-stating wording
// already covers it.
//
// This also places the nudge ahead of the attribution-shortfall sentence, so
// an imported dive with pressures on every cylinder and no flags gets the
// nudge rather than "needs an import whose gas switches account for every
// cylinder". Deliberate: both are true there, the nudge names the action
// available in the app right now, and gate 1 makes the case unreachable in
// the current corpus anyway - all 19 multi-gas dives lack a second-cylinder
// pressure.
//
// **The wording states the condition rather than issuing an instruction**,
// and that is a safety property, not a style choice. A diver with a genuinely
// staged pair that does carry both pressures would otherwise be walked into a
// flag whose figure divides the bottle's litres by the whole dive's average
// depth - the exact misattribution the multi-cylinder refusal exists to
// prevent.
const everyCylinderHasPressures = mixtures.every(
(mixture) =>
mixture.start_pressure != null && mixture.end_pressure != null,
);
if (
everyCylinderHasPressures &&
totalPressureDrop(mixtures) > 0 &&
!hasStagedCylinder(mixtures)
) {
return "If these cylinders were breathed alternately at the same depth - a sidemount pair or independent doubles - marking each of them Parallel adds their gas up into one figure. A cylinder breathed at its own depth is not that, and should be left alone.";
}
// Several cylinders *can* yield figures, from
// `compute_multi_tank_gas_use`. It gives up for five distinct reasons, and
// this is the browser's reading of which one applies. In the API's own
// order:
//
// 1. The profile carries no gas-switch attribution at all. Refuses.
// 2. Two mixtures claim one `gas_number` (or two attribution entries do),
// which makes every join ambiguous. Refuses the whole dive rather than
// just those two.
// 3. A cylinder the attribution never names. **Skipped only when its own
// pressures show no drop** - the commonest case in the corpus, a deco
// bottle with no transmitter. One that demonstrably *was* breathed
// refuses the dive: the time it was breathed for is sitting inside some
// other tank's stretch, so the surviving figures are wrong rather than
// merely partial, and the coverage fraction cannot show it.
// 4. A cylinder failing the same pressure arithmetic `compute_gas_use`
// applies is skipped. If *every* cylinder is skipped, nothing survives
// and the dive comes back empty.
// 5. A tank whose per-tank RMV comes out past `MAX_PLAUSIBLE_RMV` refuses
// the dive - case 3's fault arriving by another route, a switch recorded
// late rather than not at all.
//
// Three of those five (1, the refusing half of 3, and 5) are attribution
// faults and share one sentence, which is why it must not claim the import
// has no gas switches: on 3 and 5 it demonstrably does, and the profile
// chart is drawing their markers ten lines up the same page. It says the
// switches don't account for every cylinder instead, which is true of all
// three - including the no-switches case, vacuously.
//
// Case 2 gets no sentence: not reachable through this app, since
// `gas_number` is carried and never edited and a hand-added cylinder has
// none, so a phrase for it would be untestable wording for a state the UI
// can't produce. Case 4 is the two sentences below.
//
// Before any of that: no profile, no attribution, whatever the diver types.
// `gas_attribution` is a column *on* `dive_profile`, so a dive without one
// cannot reach the multi-tank derivation at all - and this is the ordinary
// case rather than an edge, being 18 of the 19 multi-gas dives in the
// corpus, every one of them logged by hand. Without this branch they were
// told their import's gas switches fall short of covering every cylinder,
// about an import they never made; and the "add your pressures" branch
// below would have sent the ones missing pressures off to fill in fields
// that change nothing, to be met with a different refusal.
//
// **The primary recording's profile, and no other.** The API joins the
// attribution through `ordinal == 0`: gas consumption comes from the dive's
// own diver-editable cylinders joined to a profile's switches by
// `gas_number`, and a second computer's labels are mapped onto that one list
// when its file is attached - so there is one attribution per dive, and it
// belongs to the record the dive's figures were seeded from. Asking "does
// *any* recording have a profile" here would say the attribution exists on a
// dive whose primary is the hand-logged half of a merge.
//
// `recordings` is on the detail response, and only the detail response - but
// the `mixtures` guard above has already returned for a list dive, so a
// missing profile here means the dive genuinely has none.
if (primaryRecording(dive)?.profile == null) {
return "Gas consumption for a multi-tank dive is worked out from the gas switches in an imported dive-computer file, and this dive doesn't have one.";
}
// Attribution is tested next because it is the outer gate: the API reaches
// the pressure arithmetic only after the attribution exists. The browser
// can't see that column, so the inference runs backwards - a cylinder whose
// pressures would have produced a figure, on a dive that produced none,
// means the attribution is what was missing.
const breathed = mixtures.filter(
(mixture) =>
mixture.start_pressure != null &&
mixture.end_pressure != null &&
mixture.start_pressure > mixture.end_pressure,
);
// Narrowed to the cylinders that could produce a figure *if* the attribution
// covered them. A cylinder with a drop and no recorded size is dropped by
// `_tank_arithmetic` however good the attribution is - it was counted as
// breathed and then produced no litres - so blaming the import's gas switches
// for a dive whose sizes are all missing names the one input that is not the
// problem, while the profile chart above draws the switches it says are absent.
const measurable = breathed.filter((mixture) => mixture.volume != null);
if (measurable.length > 0) {
return "Gas consumption for a multi-tank dive needs an import whose gas switches account for every cylinder on the dive.";
}
// Breathed cylinders, none of them sized. "Each", matching the pressure ask at
// the foot of this branch rather than the flagged pair's "every": this path
// divides the dive up per cylinder instead of adding it up, so there is no
// whole-set refusal to state.
if (breathed.length > 0) {
return "Add each cylinder's size to see your gas consumption.";
}
// Pressures recorded on some cylinder, but no drop on any of them - the
// multi-cylinder form of the "carried but never breathed" case below.
// Nothing to add and nothing to divide up, so neither sentence around this
// one fits.
const anyPressures = mixtures.some(
(mixture) =>
mixture.start_pressure != null && mixture.end_pressure != null,
);
if (anyPressures) {
return "No cylinder on this dive records a drop between its start and end pressure, so there is no gas used to divide up.";
}
// Plural, and no mention of average depth: the multi-tank path takes its
// depth per cylinder from the profile, so `dive.avg_depth` is not one of its
// inputs and asking for it would send a diver to fill in a field that
// changes nothing. That reasoning is about *this* path and does not
// generalize - the flagged-parallel block at the top of this branch
// asks for an average depth on a multi-cylinder dive, because the additive
// derivation genuinely divides by it. Which derivation is in play is what
// decides whether the field is worth asking for.
return "Add each cylinder's start and end pressure to see your gas consumption.";
}
const [mixture] = mixtures;
const missingDepth = dive.avg_depth == null || dive.avg_depth <= 0;
const missingPressures =
mixture.start_pressure == null || mixture.end_pressure == null;
if (missingDepth && missingPressures) {
return "Add an average depth and this tank's start and end pressure to see your gas consumption.";
}
if (missingDepth) {
// Specifically not max depth: a dive spends a moment at its deepest point,
// so using it would understate consumption badly.
return "Add an average depth to see your gas consumption.";
}
if (missingPressures) {
return "Add this tank's start and end pressure to see your gas consumption.";
}
// In `compute_gas_use`'s own position: after the depth and pressure checks and
// *before* the drop check, which is why a dive with a real drop and no size lands
// here rather than on the sentence below. It is the last of the four a diver meets,
// and the only one they can reach with every gauge reading filled in.
//
// Not folded into the combined ask above, unlike depth-and-pressures. That pairing
// exists because a form carrying `usage` over from the last dive opens with both
// blank, so asking twice would be the commonest state of the form; a blank size is
// not that state. `DEFAULT_MIXTURE` proposes 11.1 L on every cylinder added by hand,
// so the only way to arrive here is an import from a file that recorded a gas and no
// vessel - where the depth and the pressures came from the same file and are already
// filled in.
//
// "Size", not "volume": the box is labelled Volume (L) and a diver reads that as the
// gas in the cylinder as often as the cylinder itself, which is the confusion that
// makes an AL80 "80 cubic feet" and 11.1 L at once.
if (mixture.volume == null) {
return "Add this tank's size to see your gas consumption.";
}
// Both pressures recorded but no drop between them. The database already
// rejects an end pressure above the start, so this is the equal case: a tank
// that was carried but never breathed, or a typo.
return "This tank's start and end pressure are the same, so it records no gas used.";
}
// One line of the per-tank consumption table: a cylinder the dive logs, and the
// share of the gas the API attributed to it.
export interface TankGasUseRow {
// React key. Built from the cylinder's identity rather than its gas number,
// which is neither unique nor always present.
key: string;
// The cylinder's 1-based position, written bare - the number the Tanks card
// names the same cylinder by - so the two can be read against each other. A tank matching no mixture has no position to state and carries
// the device's `Gas N` instead.
label: string;
// `gasName`'s output, so this badge names the gas exactly as the Tanks card
// does - null for a cylinder whose fractions can't be named, and for a tank
// with no mixture to name it from.
gas: string | null;
role: GasRole | null;
// Whether this cylinder records both pressures. Only consulted when `use` is
// null, where it separates the two reasons a row can carry no figures - see
// below - and it is the one of the two the browser can see for itself.
hasPressures: boolean;
// Null for a cylinder with no figures. That is a real state worth a row
// rather than an omission: a pony bottle carried and never breathed belongs
// in this table saying so, and dropping it would leave the table quietly
// shorter than the Tanks card above it.
//
// **Two server states collapse into this null**, and a caller labelling the
// row must not name only the first. Either the attribution never mentioned
// the cylinder - the corpus's deco bottle with no transmitter - or it did,
// and `_tank_arithmetic` declined it: no pressures, no drop between them, or
// a degenerate stretch (`seconds <= 0`, `mean_depth_cm <= 0`, which a gas
// switched to at the surface at the end of a dive really produces). In that
// second case the coverage note below the table reports the missing seconds,
// so a row reading "not attributed" would be contradicted three lines down.
use: DiveTankGasUse | null;
}
/**
* The per-cylinder consumption rows for a dive, in the order the dive lists its
* cylinders, or an empty array when the API attributed nothing per tank.
*
* The join is `DiveMixture.gas_number` ↔ `DiveTankGasUse.gas_number`, and it
* applies the same rule the API does rather than trusting that the API applied
* it. `compute_multi_tank_gas_use` refuses a whole dive whose mixtures share a
* gas number, so such a duplicate arrives with `gas_use: null`. A flagged
* parallel set is summed without consulting `gas_number` at all, so a duplicate
* on one of those dives reaches this function with a real `gas_use` behind it -
* and lands in the arm below, harmlessly, since `tanks` is empty and every row
* comes back unattributed anyway. The guard is belt-and-braces, and deliberately
* so. It is the cheap half of a pair whose expensive half is a table showing one
* cylinder's litres twice under a total that counted them once, and the two
* sides can drift: the day the form stops carrying `gas_number` untouched, or a
* cached response predates the API's guard, this is what stands between a
* duplicate and a table that visibly doesn't add up.
*
* So a gas number naming more than one cylinder names none of them: both rows
* come back unattributed. **The rule is applied to both sides of the join**, not
* just the mixtures — two tanks sharing a number leave neither matched, and each
* lands in a row of its own. An asymmetry here would have been the same bug in
* mirror image, and a worse one: a lookup keeping the last writer drops the
* earlier tank from the table while its litres stay inside the total.
*
* A tank matching no mixture is appended rather than dropped, for the reason
* that governs all of the above: its litres are already inside the dive-wide
* total, so hiding the row would leave a total the visible rows don't add up to.
* Every tank reaches exactly one row, matched or appended, which is what makes
* that guarantee checkable.
*/
export function tankGasUseRows(dive: Dive): TankGasUseRow[] {
const tanks = dive.gas_use?.tanks ?? [];
if (tanks.length === 0) return [];
const mixtures = dive.mixtures ?? [];
const occurrences = new Map<number, number>();
for (const mixture of mixtures) {
if (mixture.gas_number == null) continue;
const number = mixture.gas_number;
occurrences.set(number, (occurrences.get(number) ?? 0) + 1);
}
// The same count on the tank side, and for the same reason. A `Map` keyed by
// gas number silently keeps the last writer, so two tanks sharing a number
// would leave the earlier one matched to nothing, skipped by the append loop
// below - which tests the number, not the tank - and therefore invisible,
// while its litres stayed inside the dive-wide total. That is the mixture-side
// failure exactly, arriving from the other end, so it gets the same answer:
// a number naming two tanks names neither, and both fall through to rows of
// their own.
const tankOccurrences = new Map<number, number>();
for (const tank of tanks) {
tankOccurrences.set(
tank.gas_number,
(tankOccurrences.get(tank.gas_number) ?? 0) + 1,
);
}
const byNumber = new Map<number, DiveTankGasUse>();
for (const tank of tanks) {
if (tankOccurrences.get(tank.gas_number) === 1) {
byNumber.set(tank.gas_number, tank);
}
}
// Tanks, not gas numbers. Identity is what "this tank already has a row"
// actually means, and keying it by number is what let a duplicate suppress a
// tank that was never rendered.
const claimed = new Set<DiveTankGasUse>();
const rows: TankGasUseRow[] = mixtures.map((mixture, index) => {
const number = mixture.gas_number;
const use =
number != null && occurrences.get(number) === 1
? (byNumber.get(number) ?? null)
: null;
if (use) claimed.add(use);
return {
// Namespaced by which of the two it is: `mixture-${id ?? index}` collides
// when some mixtures carry ids and others don't (`[{id: 1}, {}]` keys both
// to `mixture-1`). Saved mixtures always have ids, so this is unreachable
// today - but "gives every row a distinct key" is a claim a test makes
// here, and it should be true for the reason it states.
key:
mixture.id != null ? `mixture-id-${mixture.id}` : `mixture-at-${index}`,
// The Tanks card's number for the cylinder - its 1-based place in the
// list, and deliberately not its gas number, which the reader counts
// from 0. Bare, because the column is headed `#`.
label: `${index + 1}`,
gas: gasName(mixture.oxygen, mixture.helium),
role: mixture.role ?? null,
hasPressures:
mixture.start_pressure != null && mixture.end_pressure != null,
use,
};
});
tanks.forEach((tank, index) => {
if (claimed.has(tank)) return;
rows.push({
// Position in `tanks`, not the gas number, which duplicates share - two
// tanks labelled gas 1 would otherwise render under one React key.
key: `tank-${index}`,
// The device's own label is all there is to call it by. "Gas 3" rather
// than a bare "3", so it can't be misread as the Tanks card's `#3` - the
// whole point of this row is that it matches none of them.
// This is why the rows above lost the word and this one keeps it: the
// distinction was never "tank" versus "gas", it was numbered-by-position
// versus named-by-the-device, and only one of those needs saying now that
// the column is headed `#`.
label: `Gas ${tank.gas_number}`,
gas: null,
role: null,
// No mixture behind this row, so nothing to have recorded pressures. Never
// read - `use` is set - but it must not claim otherwise.
hasPressures: false,
use: tank,
});
});
return rows;
}
// Below this, the unattributed remainder is rounding rather than a gap worth a
// sentence. A minute is also the resolution the note is phrased at, so anything
// shorter would print a range whose two ends read the same.
const UNATTRIBUTED_NOTE_SECONDS = 60;
/**
* How much of the dive the per-tank figures actually account for, phrased for
* the diver, or `null` when they account for effectively all of it.
*
* Attribution is an inference from the profile, not a recorded fact, and it can
* leave a remainder: a file that records gas switches but not which cylinder the
* diver entered the water on has nothing to assign the descent to. Figures
* covering 38 of 42 minutes are still worth showing - they are right about the
* 38 - but presenting them as the whole dive would understate every one of them.
*
* **The denominator is attributed to the dive computer in so many words**, and
* has to be. It is `DiveGasUse.duration` - the profile's span, not the dive's own
* `submerged_time` - and it routinely runs longer than the dive time the same page
* prints at the top: a computer keeps sampling
* after the diver surfaces, by five minutes on dive #493. An unattributed "of the
* 1h 12min recorded" under a header reading "1h 7min" reads as one of the two
* being wrong, when both are right about different things.
*/
export function gasAttributionNote(gasUse: DiveGasUse): string | null {
const attributed = gasUse.attributed_seconds;
const total = gasUse.duration;
if (attributed == null || total == null) return null;
if (!Number.isFinite(attributed) || !Number.isFinite(total)) return null;
// A remainder can only be measured against a span. Guards the degenerate
// profile as well as the impossible over-attribution that would phrase itself
// as covering more of the dive than the dive contains.
if (total <= 0 || attributed >= total) return null;
if (total - attributed < UNATTRIBUTED_NOTE_SECONDS) return null;
return `These figures cover ${formatDurationHoursMinutes(attributed)} of the ${formatDurationHoursMinutes(total)} the dive computer recorded - the rest couldn't be assigned to a cylinder.`;
}