src/lib/dive-mixtures.ts
Rama main · réplica de lectura del repositorio original.
// The shape a mixture form field starts from, how a cylinder's role is written, and the
// breathing-gas maths that turns an O₂/He pair into something a diver recognizes - a gas
// name, a maximum operating depth, an equivalent narcotic or air depth. Pure data and
// pure functions with no React in it, and imported by `lib/dive-import.ts` - which is
// why it lives here rather than in `mixture-fields.tsx`: a `lib` module reaching into a
// `"use client"` component to pull react-hook-form, lucide-react and three shadcn
// components along with one constant is the wrong direction.
//
// `DEFAULT_MIXTURE` is re-exported from `mixture-fields.tsx` so existing call sites are
// unchanged.
//
// **Why the maths is client-side.** `services/dive_gas.py` draws the line at values
// derivable purely from stored columns; `gear_service.py` has a web twin because the
// browser holds an input the server lacks. That is this case exactly. The decisive
// input is *live form state* - the O₂ the diver is halfway through typing, which has
// not been saved and may never be - so an API round trip could not answer the
// question being asked, and a second server-side implementation would exist only to
// duplicate this one. The iOS app does carry a second implementation: `GasMath` in
// opendiving-ios's OpenDivingKit mirrors the gas name, the MOD (`mod(oxygen:ppO2:)`),
// the ppO₂ limits, the MOD warnings and the parallel-set rule, so a change to any of
// those here is owed there too. Lifting them to the API, for both to read, waits on a
// consumer that cannot carry its own copy (an export renderer, a notification job).
import type { GasRole, TankUsage } from "@/lib/api/dives";
import type { TankGas } from "@/components/icons/tank-icon";
import {
formatComparableDepth,
formatDepth,
type UnitSystem,
} from "@/lib/units";
// Every depth this module *computes* is metres, and every depth it *prints* goes
// through `formatDepth`, or `formatComparableDepth` where a limit is stated against a
// depth. The maths below is unit-blind on purpose - `METERS_PER_BAR`
// is a fact about water, not a display choice - so `units` reaches only the string
// builders, and only ever as the last step.
// Default values pre-filled when a new mixture (tank) is added. Start/end
// pressure are deliberately left blank ("") rather than defaulted, since
// they vary per tank/fill and shouldn't be guessed.
//
// `po2_limit` is blank for the same reason and one more: a MOD is the number this
// whole module exists to get right, and seeding 1.4 would make every hand-added
// cylinder claim a limit the diver never chose. Absent, `mod()` applies
// `PPO2_WORKING` itself - the same 1.4, but as a documented fallback rather than as
// a value pretending to be a decision. `role` carries no *value* either, for a third
// reason: it is a fact about the dive plan that only the diver knows.
//
// `role` is still spelled `""` rather than left off, because `""` is this form's word
// for cleared - `mergeMixture` and `toDiveMixtureInput` both write it, and
// `normalizeMixtures` converts it away at the edge. Omitting it here made a
// hand-added cylinder the one row where the field arrived `undefined`, which is the
// value react-hook-form treats as "show the default" (see `diveMixtureSchema`).
// `gas_number` is the genuine exception: no input writes it, so it has no cleared
// state to spell - it is a file's own identifier and a hand-added cylinder has none.
export const DEFAULT_MIXTURE = {
volume: 11.1,
start_pressure: "" as const,
end_pressure: "" as const,
oxygen: 21.0,
helium: 0,
po2_limit: "" as const,
role: "" as const,
// `""` for the same reason as `role`, and it carries no value for the same third
// reason too: how the cylinders were breathed is a fact about the dive only the
// diver knows, and no import can ever supply it.
usage: "" as const,
// No vessel: a hand-added tank links one only when the diver picks it.
gear_item_uuid: "",
};
// How each `GasRole` is written for a diver. Separate from the wire values, which are
// the API's vocabulary (`GasRole` in `schemas/dive_mixture.py`) - capitalized, and
// free to diverge if a role is ever better named than its enum member. Lives in this
// module, beside the maths, so the form's picker and the detail badge name a role
// identically rather than each keeping their own copy of the mapping.
//
// One word each, and the "gas" that "Bottom gas"/"Deco gas" would naturally carry is
// deliberately dropped: every place these appear supplies that word already - beside
// the gas name on a tank card, in the consumption table's column headed **Gas**, and
// under the form's **Role** label.
export const GAS_ROLE_LABELS: Record<GasRole, string> = {
bottom: "Bottom",
deco: "Deco",
diluent: "Diluent",
oxygen: "Oxygen",
};
// How each `TankUsage` is written for a diver, on the same terms as `GAS_ROLE_LABELS`
// above: separate from the wire vocabulary (`TankUsage` in `schemas/dive_mixture.py`),
// capitalized, and shared by the form's picker and the dive page so the two cannot
// name the same flag differently.
//
// One word each because it is a *name*, not a description: it is the word the diver
// picked in the form, quoted back to them on the tank's card. The meaning rides in
// the form's own longer options ("Parallel (sidemount / independent)").
export const TANK_USAGE_LABELS: Record<TankUsage, string> = {
parallel: "Parallel",
staged: "Staged",
};
// Just the flag, so a saved `DiveMixture` and a half-filled form row both satisfy the
// two predicates below - the same looseness `OxygenFractions` is built on, and the
// `""` is there for the same reason: it is how a cleared `<select>` spells itself.
interface TankUsageOnly {
usage?: TankUsage | "" | null | undefined;
}
// Whether this is a set the API will sum: two or more cylinders, every one of them
// flagged `parallel`. Mirrors the first two guards of `compute_parallel_gas_use`, and
// exists once rather than at each of its two call sites - `diveModWarning` below and
// `gasUseUnavailableReason` in `lib/dive-gas.ts` - because a set the reason text calls
// summable and the warning treats as a switch plan would be two answers to one
// question.
//
// A mixed set is deliberately not partially honoured. The API refuses one outright
// (a partial sum understates RMV), and there is nothing weaker for the browser to say.
export function isParallelSet(mixtures: readonly TankUsageOnly[]): boolean {
return (
mixtures.length >= 2 &&
mixtures.every((mixture) => mixture.usage === "parallel")
);
}
// Whether any cylinder is explicitly flagged `staged`, which is the tell of a set the
// diver has already answered for and that the API refuses by design. `gas-use`'s nudge
// keys on exactly this and nothing wider - see `gasUseUnavailableReason`.
export function hasStagedCylinder(mixtures: readonly TankUsageOnly[]): boolean {
return mixtures.some((mixture) => mixture.usage === "staged");
}
// The gas badge, sized so every cylinder's pill is the same width whatever it holds.
// The consumption table renders this badge down a column, so a pill that shrank to fit
// "Air" and grew for "EAN54" put its badges - and the role badges pinned to their
// right - at different offsets on every row.
//
// 4.5rem is 72 px, against the widest label `gasName` can return for a real gas:
// "Oxygen" at 66.8 px, measured in the rendered table at 12 px semibold. Not "EAN100",
// which looks wider and cannot occur - anything at or above `OXYGEN_MIN` is named
// "Oxygen" - so EAN tops out at "EAN99" and trimix at five characters. The remaining
// 5 px absorbs the font falling back to something a shade wider.
//
// A `min-width`, so the one label that can exceed it still fits: the spelled-out
// "O₂ 50% / He 60%" of an impossible mix, at 122 px. That row breaking the alignment
// is correct - it is the row that isn't a gas.
export const GAS_BADGE_CLASS = "min-w-[4.5rem] justify-center";
// Meters of seawater per bar of ambient pressure. Deliberately the round 10 the
// API's `METERS_PER_BAR` (`services/dive_gas.py`) already uses, not the ~10.06 a
// density-corrected figure would give: the two numbers have to agree or a depth
// shown here and an RMV computed there would quietly disagree about what a bar is,
// and the difference is far below the precision any of this is read at.
const METERS_PER_BAR = 10;
// Fraction of air that is nitrogen, as a percentage - the reference an equivalent
// air depth is equivalent *to*. 79 rather than 78.08 because EAD is defined against
// the idealized 21/79 air the tables were built on, and mixing the real atmospheric
// figure into it would put this out of step with every published table a diver might
// check it against.
const AIR_NITROGEN = 79;
// The two ppO₂ ceilings this app reasons about, in bar.
//
// Function parameters rather than a user setting, deliberately. These are not a
// preference: 1.4 is the working limit essentially every agency teaches for the
// active part of a dive, and 1.6 the contingency ceiling reserved for a
// decompression stop, where the diver is stationary and CO₂ retention is lowest.
// Making them configurable would invite exactly the edit that turns an over-MOD
// warning into silence, which is the one thing this module exists to prevent.
export const PPO2_WORKING = 1.4;
export const PPO2_DECO = 1.6;
// Whether an (O₂, He) pair is a real breathing gas that standard shorthand can name.
//
// Exported because every figure derived from a mix is only meaningful when this holds,
// so the callers that render one (`gasHintParts` here, each tank card's MOD) have to
// agree on the answer rather than each deciding for themselves.
//
// Parsed dive-file previews are not validated against the DB's
// `ck_dive_mixture_oxygen_helium_sum`, and neither is a half-typed form field, so an
// impossible mix genuinely reaches this module and must come out looking impossible
// rather than acquiring a plausible name. Zero oxygen is excluded for the same
// reason: "EAN0" is a well-formed label for a gas nobody can breathe.
// An unrecorded fraction is not a nameable mix either, and the parameters say so
// rather than leaving each caller to check first: `gasName` and `mod` beside this one
// have always taken `null | undefined`, and a cylinder may now record a mix with no
// vessel *or* a vessel with no mix, so the callers holding a stored `DiveMixture` hold
// nulls as a matter of course.
export function isNameableMix(
oxygen: number | null | undefined,
helium: number | null | undefined,
): boolean {
return (
oxygen != null &&
helium != null &&
Number.isFinite(oxygen) &&
Number.isFinite(helium) &&
oxygen > 0 &&
helium >= 0 &&
oxygen + helium <= 100
);
}
// Air is 20.9 % oxygen, devices variously record 20.9, 20.99 or 21, and divers call
// all of them air. The band is wide enough to cover that spread and narrow enough
// that a deliberately enriched EAN22 is still named as one.
const AIR_OXYGEN_MIN = 20.5;
const AIR_OXYGEN_MAX = 21.4;
// Below 100 % but close enough that the cylinder is pure oxygen with an analyzer's
// rounding on it.
const OXYGEN_MIN = 99.5;
/**
* What a diver would call this gas: `"Air"`, `"Oxygen"`, `"EAN32"` for nitrox, or
* `"21/35"` for trimix.
*
* Returns `null` when either fraction is unrecorded, which is a real state rather
* than an omission - a parsed preview reports what the file held and `null` for what
* it didn't, and a gas whose helium content is unknown cannot be told apart from air
* by any honest label. Callers name no gas in that case.
*
* Names round to whole percent, because the shorthand is integer shorthand: a 32.4 %
* fill is an EAN32 in every logbook and on every cylinder sticker. That rounding is
* safe here precisely because it is only ever a *label* - the tank cards print
* the recorded fractions unrounded under the name (see DECISIONS.md on
* matching the API's 2-decimal precision), so the exact number is never more than a
* glance away. An impossible mix falls back to spelling both fractions out.
*/
export function gasName(
oxygen: number | null | undefined,
helium: number | null | undefined,
): string | null {
if (oxygen == null || helium == null) return null;
if (!Number.isFinite(oxygen) || !Number.isFinite(helium)) return null;
if (!isNameableMix(oxygen, helium)) {
// Spelled out rather than named, so an impossible mix cannot pass for a real
// one. Only reached for finite values - a NaN fraction returns null above
// rather than rendering "O₂ NaN%".
return `O₂ ${oxygen}% / He ${helium}%`;
}
if (helium > 0) return `${Math.round(oxygen)}/${Math.round(helium)}`;
if (oxygen >= OXYGEN_MIN) return "Oxygen";
if (oxygen >= AIR_OXYGEN_MIN && oxygen <= AIR_OXYGEN_MAX) return "Air";
return `EAN${Math.round(oxygen)}`;
}
// Which of the tank icon's four markings a gas takes, on `gasName`'s bands: helium
// makes it trimix, near-pure oxygen oxygen, and any other oxygen fraction off air's
// band nitrox. A gas `gasName` cannot name draws plain, as air does.
export function tankGas(
oxygen: number | null | undefined,
helium: number | null | undefined,
): TankGas {
if (oxygen == null || helium == null || !isNameableMix(oxygen, helium)) {
return "air";
}
if (helium > 0) return "trimix";
if (oxygen >= OXYGEN_MIN) return "oxygen";
if (oxygen >= AIR_OXYGEN_MIN && oxygen <= AIR_OXYGEN_MAX) return "air";
return "nitrox";
}
/**
* Partial pressure of oxygen, in bar, breathing this mix at `depth` meters.
*
* `null` for an oxygen fraction that isn't a usable number, so a half-typed field
* shows nothing rather than `NaN`.
*
* Nothing renders this today - it is `mod` read in the other direction, and exists so
* the tests can check the pair against each other (a MOD is by definition the depth
* where this returns the limit it was given). Kept for that and for the next caller
* that needs a ppO₂ rather than a depth; don't go hunting for the call site.
*/
export function ppO2AtDepth(
depth: number,
oxygen: number | null | undefined,
): number | null {
if (oxygen == null || !Number.isFinite(oxygen) || !Number.isFinite(depth)) {
return null;
}
return (depth / METERS_PER_BAR + 1) * (oxygen / 100);
}
/**
* Maximum operating depth in meters - how deep this mix can be breathed before its
* ppO₂ passes `ppO2` bar. Defaults to the working limit; pass `PPO2_DECO` for the
* stop-only contingency ceiling.
*
* `null` when oxygen isn't a positive number to divide by. A hypothetical 0 % mix
* has no MOD rather than an infinite one, and returning `Infinity` would render.
*/
export function mod(
oxygen: number | null | undefined,
ppO2: number = PPO2_WORKING,
): number | null {
if (oxygen == null || !Number.isFinite(oxygen) || oxygen <= 0) return null;
const depth = (ppO2 / (oxygen / 100) - 1) * METERS_PER_BAR;
// A mix already past its ppO₂ limit at the surface has no operating depth at all.
// `null`, not `Math.max(0, …)` as `endDepth` and `ead` use: 0 m is a real answer for
// those two — a rich mix in shallow water genuinely is equivalent to the surface —
// whereas "MOD 0 m" reads as a depth this gas may be breathed at, which is the
// opposite of what it means. Both call sites already render `-` for null.
//
// Unreachable until this branch: `mod` was only ever called with the 1.4/1.6
// constants, which need oxygen above 140 % to go negative. A diver-editable
// `po2_limit` down to 0.4 puts it one plausible EAN50 away.
return depth < 0 ? null : depth;
}
/**
* The ppO₂ a cylinder's MOD should be worked out at: what the dive recorded, or
* `PPO2_WORKING` when it recorded nothing.
*
* One place, because a MOD shown against a limit other than the one it was computed
* from is worse than no MOD at all, and three callers were about to make this choice
* independently.
*/
export function ppO2Limit(mixture: { po2_limit?: number | null }): number {
const limit = mixture.po2_limit;
return limit != null && Number.isFinite(limit) ? limit : PPO2_WORKING;
}
export interface EndOptions {
// Whether oxygen is counted as narcotic. Default true, which is the conservative
// reading and the one most agencies now teach: oxygen's narcotic potency is
// comparable to nitrogen's, so only the helium fraction genuinely buys back
// clarity. Setting it false reverts to the older N₂-only convention, which gives a
// shallower - more flattering - number for the same gas.
o2Narcotic?: boolean;
}
/**
* Equivalent narcotic depth in meters: the air depth whose narcosis matches
* breathing this mix at `depth`.
*
* The point of trimix stated as a depth. Returns `null` on unusable inputs.
*
* Floored at 0, exactly as `ead` is and for the same reason: a helium mix in shallow
* water is less narcotic than air at the surface, and the arithmetic runs negative
* below about 8 m. That is reachable in the live form the moment a diver has typed
* the "4" of a "45" into the depth box.
*/
export function endDepth(
depth: number,
helium: number | null | undefined,
oxygen: number | null | undefined,
{ o2Narcotic = true }: EndOptions = {},
): number | null {
if (helium == null || !Number.isFinite(helium) || !Number.isFinite(depth)) {
return null;
}
// With oxygen counted narcotic, everything that isn't helium is, so the mix's
// oxygen fraction never enters - which is why `oxygen` may be absent in that mode.
const narcoticFraction = o2Narcotic
? 1 - helium / 100
: (() => {
if (oxygen == null || !Number.isFinite(oxygen)) return null;
return (100 - oxygen - helium) / 100;
})();
if (narcoticFraction == null) return null;
const equivalent =
(depth + METERS_PER_BAR) * narcoticFraction - METERS_PER_BAR;
return Math.max(0, equivalent);
}
/**
* Equivalent air depth in meters: the air depth carrying the same nitrogen load as
* this nitrox mix at `depth`, and so the depth its decompression is planned to.
*
* Nitrox only - a mix with helium in it has an END, not an EAD, and passing one here
* returns `null` rather than a number that would understate the real nitrogen
* exposure. Floored at 0: a rich mix in shallow water is equivalent to the surface,
* and a negative depth is not a thing.
*/
export function ead(
depth: number,
oxygen: number | null | undefined,
helium: number | null | undefined = 0,
): number | null {
if (oxygen == null || !Number.isFinite(oxygen) || !Number.isFinite(depth)) {
return null;
}
// `helium > 0` alone would let NaN through - it compares false - and compute an
// EAD that silently ignores the helium the caller could not measure.
if (helium == null || !Number.isFinite(helium) || helium > 0) return null;
const nitrogen = 100 - oxygen;
const equivalent =
((depth + METERS_PER_BAR) * nitrogen) / AIR_NITROGEN - METERS_PER_BAR;
return Math.max(0, equivalent);
}
// Just the fields the oxygen-exposure warnings need, so both a saved `DiveMixture`
// and a half-filled form row satisfy it without either type being imported here.
export interface OxygenFractions {
// `""` for the reason `po2_limit` and `usage` below give, and it reached these two
// last: a cleared O₂ or He box is now a live form value, because a cylinder may
// record a vessel with no mix. `recordedFraction` is what every reader here puts it
// through.
//
// Optional as well as nullable, which `helium` already was and `oxygen` has just
// become: the key is absent on a `DiveMixture` too, and requiring it here only
// forced the two callers holding one to spread it back in - a promise the wire never
// made, which is the same mistake `DiveMixture` itself was corrected for.
oxygen?: number | "" | null | undefined;
helium?: number | "" | null | undefined;
// Accepted and **deliberately never read** - `PPO2_WORKING`/`PPO2_DECO` are what
// the warnings judge against, and a dive's own recorded limit must not be able to
// move them (see `modWarning`). Declared rather than left off so that reading
// right here is the answer to "does the limit feed the warning?", instead of the
// question surviving as an excess-property error at one call site.
//
// `""` is in the union for the same reason the rest of this interface is loose:
// a live form row spells a cleared number that way, and this type exists to be
// satisfied by both a saved `DiveMixture` and a half-filled one.
po2_limit?: number | "" | null | undefined;
// How the cylinder was breathed, and - unlike `po2_limit` above - genuinely read:
// `diveModWarning` uses it to tell a sidemount pair from a set of cylinders
// breathed at different depths, which is the one thing that decides whether a
// single mix can be judged against the dive's maximum depth.
//
// `""` is in the union for the reason the interface's lead comment gives, the same
// one `po2_limit` carries: a live form row spells a cleared select that way, and
// this type has to be satisfied by a half-filled row as well as a saved one.
usage?: TankUsage | "" | null | undefined;
}
// One recorded fraction, or `null` for every way of not having one - a cleared form
// box, an API `null`, a field that was never there. The three functions below take
// whole `OxygenFractions` rather than loose numbers, so this is where their `""`
// stops: everything downstream (`gasName`, `mod`, `modWarning`'s comparisons) already
// deals in numbers and nulls, and letting `""` past would have `0`-coerce it into a
// gas nobody can breathe.
function recordedFraction(
value: number | "" | null | undefined,
): number | null {
return typeof value === "number" ? value : null;
}
// The margin a depth has to clear a limit by to count as past it, in meters.
//
// `mod` divides by a fraction with no exact binary form, so a limit that is round in
// decimal comes back a few ULPs under it: EAN28's 1.4 limit is 39.999999999999993, so
// a bare `>` puts a 40 m dive on EAN28 past a limit the same sentence prints as
// "40 m". EAN40 at 25 m and oxygen at 4 m sit the same way and EAN32's 33.75 m does
// not - which side the error falls on is a property of the constants rather than of
// the question, so every depth-against-limit comparison here goes through this.
//
// A nanometre: far above the ~1e-14 at stake, far below the centimetre a dive file
// records.
const DEPTH_TOLERANCE = 1e-9;
// Whether `depth` is past `limit` by more than the rounding error above.
function isPastLimit(depth: number, limit: number): boolean {
return depth > limit + DEPTH_TOLERANCE;
}
/**
* Why this mix is a problem at `breathedDepth`, phrased for the diver, or `null` when
* it isn't one.
*
* **`breathedDepth` must be a depth this gas was actually breathed at.** It is not
* the dive's maximum depth unless the two genuinely coincide, which is only knowable
* for a dive logging a single cylinder. A staged deco bottle is carried to the bottom
* and breathed on the ascent, so judging it against the deepest point of the dive
* reports every correctly planned decompression as a problem. Pass `null` when the
* depth this gas saw is unknown - see `diveModWarning`, which is what callers holding
* a whole dive should use.
*
* Two distinct findings, not one with a threshold: past the 1.4 working limit the
* gas is still breathable where a diver is decompressing but not where they are
* working, which is a planning note. Past 1.6 there is no depth at which it was
* appropriate, which is a different sentence and a louder one.
*
* **A recorded `po2_limit` deliberately does not move these thresholds**, even though
* it moves the MOD displayed beside them. The two are different claims: a tank
* card's MOD says what the diver planned this gas to, while this says what the gas can
* physiologically take. Letting the dive's own number set the limit it is judged
* against would make a cylinder recorded at ppO₂ 2.0 unwarnable - the "edit that
* turns an over-MOD warning into silence" that `PPO2_WORKING`/`PPO2_DECO` are
* constants to prevent, arriving through a file instead of a settings screen.
*/
export function modWarning(
mixture: OxygenFractions,
breathedDepth: number | null | undefined,
units: UnitSystem,
): string | null {
if (breathedDepth == null || !Number.isFinite(breathedDepth)) return null;
const oxygen = recordedFraction(mixture.oxygen);
const decoLimit = mod(oxygen, PPO2_DECO);
const workingLimit = mod(oxygen, PPO2_WORKING);
if (decoLimit == null || workingLimit == null) return null;
// Both figures print at one scale and the limit rounds down, which is what keeps a
// sentence about a depth recorded to the centimetre from putting the same number on
// both sides of "is past". A parsed depth carrying finer decimals still can, and so
// does imperial inside a tenth of a foot - see `formatComparableDepth`.
if (isPastLimit(breathedDepth, decoLimit)) {
return `${formatComparableDepth(breathedDepth, units)} is past this mix's ${formatComparableDepth(decoLimit, units, { floor: true })} limit at ppO₂ ${PPO2_DECO}.`;
}
if (isPastLimit(breathedDepth, workingLimit)) {
return `${formatComparableDepth(breathedDepth, units)} is past this mix's ${formatComparableDepth(workingLimit, units, { floor: true })} working limit (ppO₂ ${PPO2_WORKING}); it is within the ${PPO2_DECO} ceiling used for decompression.`;
}
return null;
}
/**
* The figures worth showing under one cylinder's O₂/He boxes, in reading order, or
* an empty array when the gas isn't yet identifiable.
*
* Always: the gas's name and its MOD, which are properties of the gas alone.
*
* Conditionally: END for a helium mix, EAD for nitrox - and neither for air, where
* both equal the depth itself and `EAD 30.0 m at 30 m` is noise dressed as
* information. Both are claims about a depth the gas was breathed at, so `depth`
* must be `null` whenever that is unknown, which is any dive logging more than one
* cylinder (see `diveModWarning`).
*
* Lives here rather than in the component so the *choice* of figure is covered by
* the same tests as the maths behind it - and so deciding "is this air?" is a
* comparison of numbers rather than of `gasName`'s output string, which would
* silently break the day that label changed.
*/
export function gasHintParts({
oxygen,
helium,
depth,
ppO2,
units,
}: {
oxygen: number | null | undefined;
helium: number | null | undefined;
depth: number | null | undefined;
// The ppO₂ this gas was planned to, when the dive records one. Absent falls back
// to `PPO2_WORKING` - the same 1.4, but the fallback is what the printed "@ ppO₂
// 1.4" then describes, so the label always names the limit the number came from.
ppO2?: number | null | undefined;
// Which system the depths in these strings are written in. ppO₂ is not one of
// them: it is bar in both, as it is on every dive computer ever made.
units: UnitSystem;
}): string[] {
const name = gasName(oxygen, helium);
if (name === null) return [];
const parts = [name];
// An impossible mix gets its spelled-out name and nothing further. MOD, END and
// EAD would each be a confident figure derived from fractions that cannot coexist
// in one cylinder - the same "don't let it pass for a real gas" rule `gasName`
// applies to the label, carried through to the numbers under it.
if (oxygen == null || helium == null || !isNameableMix(oxygen, helium)) {
return parts;
}
const limit = ppO2Limit({ po2_limit: ppO2 });
const workingMod = mod(oxygen, limit);
if (workingMod !== null) {
parts.push(
`MOD ${formatComparableDepth(workingMod, units, { floor: true })} @ ppO₂ ${limit}`,
);
}
if (depth == null || !Number.isFinite(depth)) return parts;
const isAir =
helium === 0 &&
oxygen != null &&
oxygen >= AIR_OXYGEN_MIN &&
oxygen <= AIR_OXYGEN_MAX;
if (isAir) return parts;
if (helium != null && helium > 0) {
const end = endDepth(depth, helium, oxygen);
// Qualified for the same reason a tank card's MOD names its ppO₂: a diver
// taught the older nitrogen-only convention computes 14.2 m where this says
// 25.8 m for the same gas, and nothing else on screen explains the gap.
if (end !== null) {
parts.push(
`END ${formatDepth(end, units, { decimals: 1 })} at ${formatDepth(depth, units)} (O₂ narcotic)`,
);
}
return parts;
}
const equivalent = ead(depth, oxygen, helium);
if (equivalent !== null) {
parts.push(
`EAD ${formatDepth(equivalent, units, { decimals: 1 })} at ${formatDepth(depth, units)}`,
);
}
return parts;
}
// A flagged parallel set carrying one gas: the case `diveModWarning` can judge as a
// single cylinder. Both halves are required - see that function's doc comment just
// below - and the gas comparison uses the recorded fractions rather than `gasName`,
// which rounds: two rows at 31.6% and 32.4% are both "EAN32" and are not the same fill.
//
// `helium` is normalized to 0 because that is what the form writes for a non-trimix,
// while `OxygenFractions` allows it absent - and an absent one genuinely arrives, from
// an import that writes no 0 the file never recorded.
// That normalization is safe where the same one on oxygen would not be: every figure
// this predicate feeds is derived from the oxygen fraction alone (`modWarning`), so a
// pairing that is wrong about helium changes nothing that gets warned about, while one
// that is wrong about oxygen picks a cylinder's MOD to judge the whole dive by.
//
// **An unrecorded oxygen is not a gas two cylinders can share.** Two rows that both say
// nothing are equal as values and identical about nothing, so comparing them raw would
// call a pair of unanalysed cylinders one mix and hand `diveModWarning` the first of
// them to judge. It answers `null` for that pair anyway - `mod(null)` has no depth to
// give - but the agreement would be an accident of what the caller does next rather
// than of what this predicate claims, and a later caller reading it as "these hold the
// same gas" would be reading it wrong.
//
// It sits *above* that doc block rather than between it and its function: a `/** */`
// binds to the next declaration whatever `//` comments intervene, so parking a helper
// in the gap silently reassigns ~50 lines of documentation to it and leaves
// `diveModWarning` with none.
export function isSingleGasParallelSet(
mixtures: readonly OxygenFractions[],
): boolean {
if (!isParallelSet(mixtures)) return false;
const [first] = mixtures;
const firstOxygen = recordedFraction(first.oxygen);
if (firstOxygen === null) return false;
return mixtures.every(
(mixture) =>
recordedFraction(mixture.oxygen) === firstOxygen &&
(recordedFraction(mixture.helium) ?? 0) ===
(recordedFraction(first.helium) ?? 0),
);
}
/**
* The oxygen-exposure warning for a whole dive, or `null` when there is nothing
* honest to say.
*
* This exists because a dive does not record which cylinder was breathed at which
* depth - the same gap that stops `gasUseUnavailableReason` deriving RMV for a
* multi-tank dive. What can be concluded splits cleanly in two:
*
* - **One cylinder logged.** It was breathed throughout, so the dive's maximum depth
* is a depth this gas genuinely saw and `modWarning` applies directly, both
* thresholds included.
* - **Several cylinders logged, all flagged `parallel` and all holding one gas.** A
* sidemount pair or independent doubles breathed alternately at the same depth is
* one gas plan, not a switch plan: there is only one mix on board and it was
* breathed throughout, so the dive's maximum depth is a depth it genuinely saw and
* the single-cylinder reasoning applies unchanged, working limit included. Both
* halves of that are load-bearing - the flag says the cylinders were breathed
* together, and the equal `(oxygen, helium)` says there is nothing to choose
* between. A flagged pair holding *different* gases is a switch plan again, and
* falls to the case below.
*
* This is not the per-tank-attribution argument rejected further down, and that
* rejection does not reach it. Attribution offers a *mean* depth per cylinder,
* which is the wrong input for a MOD; this offers the diver's own statement that
* every cylinder saw the same depths, which is the right one. Nothing is being
* inferred from a profile here.
*
* - **Several cylinders logged.** Which one was breathed at the bottom is unknown, so
* no single mix can be judged - a staged deco bottle is *supposed* to have a MOD
* far shallower than the dive. The one sound inference left is that if the deepest-
* capable gas on board still cannot reach the maximum depth, then no gas could
* have, whichever order they were breathed in. That is a real finding (a depth
* typo, or a genuinely unplanned dive) and it is reported against the dive rather
* than blamed on any one cylinder. A cylinder with no usable oxygen fraction - a
* box mid-retype, or an import from a file that recorded no analysis - is left out
* of the maximum rather than silencing the sentence; see the comment at that branch
* for why the weaker claim is the right one.
*
* The 1.4 working limit is deliberately not applied in the multi-cylinder case: a
* deco gas exceeding 1.4 somewhere on the dive is the normal, intended state of
* affairs, and saying so on every technical dive is the noise this function exists to
* avoid.
*
* Per-tank attribution has since landed (`DiveGasUse.tanks`) and is *not* enough to
* lift this restriction. It reports a **mean** depth per cylinder, which is the right
* input for a consumption rate and the wrong one for a MOD: a gas averaging 6 m may
* still have been breathed at 20 m for a minute, and warning against the mean would
* clear exactly the excursion worth warning about. What this needs is a deepest-point
* per gas, which nothing sends yet.
*/
export function diveModWarning(
mixtures: readonly OxygenFractions[],
maxDepth: number | null | undefined,
units: UnitSystem,
): string | null {
if (maxDepth == null || !Number.isFinite(maxDepth)) return null;
if (mixtures.length === 0) return null;
if (mixtures.length === 1) return modWarning(mixtures[0], maxDepth, units);
// A flagged parallel set holding one gas is judged as that gas, for the reason this
// function's own doc comment gives. Read off the first row because
// `isSingleGasParallelSet` has already established that every row matches it.
if (isSingleGasParallelSet(mixtures)) {
return modWarning(mixtures[0], maxDepth, units);
}
// A cylinder with no usable oxygen fraction is left out of the maximum rather than
// silencing the sentence, and a nullable `oxygen` does not change that. It reads
// like a weaker claim than "no gas logged can reach" deserves - an unanalysed
// cylinder could have held the deep mix - but the direction of the error is what
// settles it: a warning that vanishes because one row is blank is the missing
// warning `PPO2_WORKING` is a constant to prevent, arriving through an import
// instead of a settings screen, and it would flicker off every time a diver cleared
// an O₂ box to retype it. The gases that *are* logged still cannot reach the depth,
// which is a true and useful thing to say.
const limits = mixtures
.map((mixture) => mod(recordedFraction(mixture.oxygen), PPO2_DECO))
.filter((limit): limit is number => limit !== null);
if (limits.length === 0) return null;
const deepest = Math.max(...limits);
if (!isPastLimit(maxDepth, deepest)) return null;
return `No gas logged for this dive can be breathed at ${formatComparableDepth(maxDepth, units)} - the deepest-capable of them reaches ${formatComparableDepth(deepest, units, { floor: true })} at ppO₂ ${PPO2_DECO}.`;
}