DEV Community

Kurotu Sacebo
Kurotu Sacebo

Posted on

Building a Realistic ETG Calculator: From BAC to ETG Detection Window (with JS & Python code)

Disclaimer

The code and math below are for educational purposes only and provide estimates, not guarantees. ETG test outcomes depend on biology, lab cutoffs, and context. Use responsibly.

If you've ever tried to figure out “Will ETG still be detectable?” you know it's not just a single formula. A realistic ETG calculator typically combines:
1) a BAC model (alcohol presence over time), and

2) an ETG detection tail (how long ETG can remain after ethanol is metabolized).

In this post we'll build a clean, testable implementation you can ship in production. We’ll write it in TypeScript/JavaScript first (Node/browser), and then provide a Python equivalent.


🧠 Model overview

We’ll model three layers:

  1. Alcohol grams from drinks
  2. BAC curve via a Widmark-like model with a constant elimination rate β
  3. ETG detection window as a calibrated heuristic based on total intake and peak BAC

Key ideas (simplified):

  • Convert drinks → pure alcohol (grams).
  • Compute standard drinks = grams / 14 (US convention).
  • Approximate peak BAC using Widmark and then apply a fixed elimination rate β (g/dL per hour).
  • Estimate ETG detectability tail (hours after BAC returns ~0). We’ll use a capped, data-informed heuristic:
    • tail = clamp(24 + 6 * stdDrinks, 24, 80)
    • You can re‑tune constants with real‑world data or validation sets.

Why a heuristic? Because actual ETG windows depend on lab cutoff (e.g., 100 ng/mL vs 500 ng/mL), hydration, renal function, incidental exposure, etc. A “deterministic” mapping is not clinically valid. Heuristics let you surfacing a range, not a promise.


📏 Assumptions & constants

  • Body water ratio (r) ~ 0.68 for male, 0.55 for female (tweakable).
  • Elimination rate (β) ≈ 0.015 g/dL/hour (commonly used average).
  • Ethanol density ρ0.789 g/mL.
  • Standard drink (US): 14 g ethanol.

🧮 JavaScript / TypeScript implementation

type Sex = "male" | "female";

interface Drink {
  /** Volume in milliliters */
  ml: number;
  /** ABV as percentage (e.g., 5 for 5%) */
  abvPct: number;
}

interface EtgInput {
  weightKg: number;
  sex: Sex;
  /** Clock time of the first drink in local time (ms since epoch). Optional, used for timestamps */
  startedAtMs?: number;
  /** Minutes over which drinks were consumed */
  durationMin: number;
  drinks: Drink[];
  /** Elimination rate in g/dL/hour; default 0.015 */
  beta?: number;
}

interface EtgResult {
  totalGrams: number;
  standardDrinks: number;
  peakBac_gPerDl: number;
  timeToZeroHours: number;
  etgTailHours: number;
  /** total hours from last sip until ETG expected to be below threshold */
  expectedUndetectableHours: number;
  /** Optional absolute timestamp if startedAtMs provided */
  expectedUndetectableAtMs?: number;
}

/** Convert drink list to grams of pure ethanol */
export function gramsOfAlcohol(drinks: Drink[]): number {
  const ethanolDensity = 0.789; // g/mL
  return drinks.reduce((sum, d) => {
    const pureMl = d.ml * (d.abvPct / 100);
    return sum + pureMl * ethanolDensity;
  }, 0);
}

/** Compute standard drinks (US) */
export function toStandardDrinks(grams: number): number {
  return grams / 14;
}

/** Widmark r by sex (override as needed) */
export function widmarkR(sex: Sex): number {
  return sex === "male" ? 0.68 : 0.55;
}

/**
 * Estimate peak BAC (g/dL) at end of the drinking window.
 * Simplified: all intake considered distributed over duration, no absorption lag.
 *
 * Widmark(metric-ish): BAC ≈ (A / (r * W_kg)) * K - beta * t
 * We'll use K≈1.2 to get into g/dL ballpark for metric inputs.
 */
export function peakBac({
  totalGrams,
  weightKg,
  sex,
  durationMin,
  beta = 0.015,
}: {
  totalGrams: number;
  weightKg: number;
  sex: Sex;
  durationMin: number;
  beta?: number;
}): number {
  const r = widmarkR(sex);
  const K = 1.2; // unit fudge to approximate g/dL from metric inputs
  const tHours = Math.max(0, durationMin / 60);
  // BAC at the end of drinking bout
  const bac = Math.max(0, (totalGrams / (r * weightKg)) * K - beta * tHours);
  return bac;
}

/** Hours to metabolize down to ~0 g/dL from a given BAC using elimination beta */
export function hoursToZero(bac_gPerDl: number, beta = 0.015): number {
  return bac_gPerDl <= 0 ? 0 : bac_gPerDl / beta;
}

/**
 * Heuristic ETG tail after BAC ≈ 0.
 * Capped between 24h and 80h, scaled by total standard drinks.
 */
export function etgTailHours(stdDrinks: number): number {
  const tail = 24 + 6 * stdDrinks;
  return Math.max(24, Math.min(80, tail));
}

/**
 * Main ETG estimate: from LAST SIP to expected undetectable.
 * We assume last sip at (startedAt + duration).
 */
export function estimateEtgWindow(input: EtgInput): EtgResult {
  const totalGrams = gramsOfAlcohol(input.drinks);
  const standardDrinks = toStandardDrinks(totalGrams);
  const peak = peakBac({
    totalGrams,
    weightKg: input.weightKg,
    sex: input.sex,
    durationMin: input.durationMin,
    beta: input.beta,
  });
  const timeZero = hoursToZero(peak, input.beta ?? 0.015);
  const tail = etgTailHours(standardDrinks);
  const expectedUndetectableHours = tail; // from last sip

  const res: EtgResult = {
    totalGrams,
    standardDrinks,
    peakBac_gPerDl: peak,
    timeToZeroHours: timeZero,
    etgTailHours: tail,
    expectedUndetectableHours,
  };

  if (input.startedAtMs !== undefined) {
    const lastSipMs = input.startedAtMs + input.durationMin * 60_000;
    res.expectedUndetectableAtMs = lastSipMs + expectedUndetectableHours * 3_600_000;
  }

  return res;
}

// ------------------- Example -------------------

if (typeof require !== "undefined" && require.main === module) {
  // Example: 3 beers 330 mL @ 5% over 2 hours, 75 kg male
  const result = estimateEtgWindow({
    weightKg: 75,
    sex: "male",
    durationMin: 120,
    drinks: [
      { ml: 330, abvPct: 5 },
      { ml: 330, abvPct: 5 },
      { ml: 330, abvPct: 5 },
    ],
    startedAtMs: Date.now(),
  });

  console.log(result);
}
Enter fullscreen mode Exit fullscreen mode

Notes on calibration

  • The K constant in peakBac converts metric units to a typical g/dL scale. You can fit this using reference cases or public datasets.
  • β can vary (≈ 0.010–0.020 g/dL/h). Consider bounding outputs as ranges when you vary β ±20%.
  • You can also include absorption lag (e.g., peak ~30–60 min after a drink) by time‑stepping the BAC curve (see Python version below).

🐍 Python version (with simple time‑stepping)

from dataclasses import dataclass
from typing import List, Optional

ETHANOL_DENSITY_G_PER_ML = 0.789  # g/mL

@dataclass
class Drink:
    ml: float       # volume in mL
    abv_pct: float  # ABV in %

@dataclass
class EtgInput:
    weight_kg: float
    sex: str  # "male" or "female"
    duration_min: float
    drinks: List[Drink]
    beta: float = 0.015  # g/dL per hour
    started_at_ms: Optional[int] = None

@dataclass
class EtgResult:
    total_grams: float
    standard_drinks: float
    peak_bac_g_per_dl: float
    time_to_zero_hours: float
    etg_tail_hours: float
    expected_undetectable_hours: float
    expected_undetectable_at_ms: Optional[int]

def widmark_r(sex: str) -> float:
    return 0.68 if sex.lower() == "male" else 0.55

def grams_of_alcohol(drinks: List[Drink]) -> float:
    grams = 0.0
    for d in drinks:
        grams += d.ml * (d.abv_pct / 100.0) * ETHANOL_DENSITY_G_PER_ML
    return grams

def to_standard_drinks(grams: float) -> float:
    return grams / 14.0

def etg_tail_hours(std_drinks: float) -> float:
    tail = 24 + 6 * std_drinks
    return max(24.0, min(80.0, tail))

def simulate_bac_peak(total_grams: float, weight_kg: float, sex: str, duration_min: float, beta: float = 0.015) -> float:
    """
    Crude time-step simulation: distribute intake evenly across duration and apply elimination.
    Returns estimated peak BAC (g/dL).
    """
    r = widmark_r(sex)
    K = 1.2  # scale metric -> g/dL
    steps = max(1, int(duration_min))
    grams_per_min = total_grams / steps

    bac = 0.0
    peak = 0.0
    for minute in range(steps):
        # add small dose for this minute
        bac += (grams_per_min / (r * weight_kg)) * K
        # eliminate for one minute
        bac = max(0.0, bac - (beta / 60.0))
        peak = max(peak, bac)
    return peak

def estimate_etg_window(inp: EtgInput) -> EtgResult:
    total_grams = grams_of_alcohol(inp.drinks)
    std_drinks = to_standard_drinks(total_grams)
    peak = simulate_bac_peak(total_grams, inp.weight_kg, inp.sex, inp.duration_min, inp.beta)
    time_zero = peak / inp.beta if inp.beta > 0 else float('inf')
    tail = etg_tail_hours(std_drinks)
    expected_undetectable_hours = tail

    expected_at = None
    if inp.started_at_ms is not None:
        last_sip_ms = inp.started_at_ms + int(inp.duration_min * 60_000)
        expected_at = last_sip_ms + int(expected_undetectable_hours * 3_600_000)

    return EtgResult(
        total_grams=total_grams,
        standard_drinks=std_drinks,
        peak_bac_g_per_dl=peak,
        time_to_zero_hours=time_zero,
        etg_tail_hours=tail,
        expected_undetectable_hours=expected_undetectable_hours,
        expected_undetectable_at_ms=expected_at,
    )

if __name__ == "__main__":
    ex = EtgInput(
        weight_kg=75.0,
        sex="male",
        duration_min=120,
        drinks=[Drink(ml=330, abv_pct=5.0) for _ in range(3)]
    )
    print(estimate_etg_window(ex))
Enter fullscreen mode Exit fullscreen mode

✅ Practical UX: return ranges

For production, don’t return a single number. Propagate uncertainty to produce a low–high range by varying assumptions:

  • β ∈ [0.012, 0.018] g/dL/h
  • Change r by ± 0.03
  • Tail coefficients (e.g., 24 + 4–8 * stdDrinks)

Return something like:

{
  "expectedUndetectableHours": { "low": 36, "high": 72 },
  "explanations": [
    "Varied elimination rate β",
    "Rounded to nearest 6 hours for clarity"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This keeps expectations realistic and reduces false precision.


🔬 Validation checklist

  • Compare against reference cases and adjust K, tail coefficients.
  • Offer lab cutoff selector (e.g., 100 vs 500 ng/mL) to scale the tail (lower cutoff ⇒ longer tail).
  • Add a hydration / small‑exposure warning (sanitizers, mouthwash, etc.).
  • Add unit tests for edge cases (very small/large body mass, binge vs spaced drinks).

📎 Live calculator

Want to see a working implementation in the wild? Try: etgcalculatoronline.com


📚 License

MIT for the snippets above. Please retain attribution if you publish derivatives.

Top comments (0)