GlucoseIQ

Data Quality

Handle malformed records, coverage, gaps, interpolation, and non-computable results.

Validate and preserve source readings before calculating metrics. Report rejected rows and mark interpolated points as derived.

Choose the check you need

NeedAPI or resultHost responsibility
Skip one malformed vendor rowsafeNormalize*EntriesRecord or show the rejected-row count
Audit skipped CSV rowsparseGlucoseCSVCompare source and accepted row counts when an audit is required
Check report validityreport.validShow an empty state for an invalid report
Measure available historyreport.dataSufficiency or calculateActivePercentShow coverage separately from analytical values
Find missing readingsdetectGapsChoose a threshold based on the expected time between reading timestamps
Build a regular chart gridalignToGridKeep interpolated points separate from observed readings

Choose strict or partial-success normalization

The vendor array normalizers are strict at the batch level. If one entry throws, the array call throws and does not return the other normalized readings:

import { normalizeDexcomEntries } from '@glucoseiq/core/connectors'

const readings = normalizeDexcomEntries([
  { Value: 112, Trend: 'Flat', WT: '/Date(1705320000000)/' },
  { Value: 118, Trend: 'SingleUp', WT: '/Date(1705320300000)/' },
])

For long-running feeds and imported history, the safe variants retain valid entries and collect failures by original array index:

import { safeNormalizeDexcomEntries } from '@glucoseiq/core/connectors'

const { readings, errors } = safeNormalizeDexcomEntries([
  { Value: 112, Trend: 'Flat', WT: '/Date(1705320000000)/' },
  { Value: 118, Trend: 'SingleUp', WT: 'not-a-date' },
])

for (const error of errors) {
  console.warn(`Rejected source row ${error.index}: ${error.message}`)
}

The same pattern is available as safeNormalizeLibreEntries and safeNormalizeNightscoutEntries. Successful readings are returned in chronological order. Each error currently contains index and message.

Do not hide partial failure

A usable report and an imperfect import can both be true. Keep the returned readings, but record or show the rejected-row count so users know the dataset was incomplete.

Know what CSV ingestion skips

parseGlucoseCSV throws a ParseError with code CSV_COLUMN_NOT_FOUND when a requested header is absent. After it finds the columns, it skips rows whose value is not positive and finite or whose timestamp cannot be parsed.

import { GlucoseIQError, parseGlucoseCSV } from '@glucoseiq/core'

const csvText = `Timestamp,Glucose Value (mg/dL)
2026-07-01T08:00:00Z,112`

try {
  const readings = parseGlucoseCSV(csvText, {
    timestampColumn: 'Timestamp',
    valueColumn: 'Glucose Value (mg/dL)',
    unit: 'mg/dL',
  })
} catch (error) {
  if (error instanceof GlucoseIQError && error.code === 'CSV_COLUMN_NOT_FOUND') {
    // Ask the user to map the correct columns.
  } else {
    throw error
  }
}

The parser accepts header-row delimited data with exact mapped timestamp and value columns. Blank lines are ignored. A header-only document returns no readings after both mapped columns are validated. Quoted fields and doubled quotes work within one physical line, but a quoted field cannot contain a physical newline. Rows with a non-positive or non-finite value or an unparseable timestamp are skipped without per-row rejection details.

The delimiter defaults to comma and must be exactly one UTF-16 code unit other than double quote, NUL, carriage return, or line feed. An invalid delimiter throws a DomainError with code INVALID_OPTION. If an import audit is required, compare the source row count with the returned reading count or validate rows in your ingestion layer before calling the parser.

Check report validity first

analyzeGlucose performs its own cleaning pass. It keeps readings with a parseable timestamp and a positive, finite value no greater than 600 mg/dL after unit conversion. If no reading survives, it returns a non-throwing empty result:

import { analyzeGlucose, type GlucoseReading } from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
]

const report = analyzeGlucose(readings)

if (!report.valid) {
  // meanGlucose, gmi, cv, sd, and activePercent are NaN.
  // timeInRange, tightRange, risk, agpProfile, and episodes are null.
  const emptyState = 'No valid glucose readings are available.'
  console.info(emptyState)
}

Do not rely on truthiness for numeric metrics: zero can be meaningful and NaN is truthy. Use the result's valid field and Number.isFinite when reading a metric that may be non-computable.

import { analyzeGlucose, type GlucoseReading } from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
]
const report = analyzeGlucose(readings)
const label = Number.isFinite(report.cv) ? `${report.cv}%` : 'Not computable'

Individual functions have their own documented behavior. For example, calculateEnhancedTIR([]) throws EmptyDatasetError, buildAGPProfile([]) returns valid: false, and calculateActivePercent returns NaN for active percent when fewer than two valid timestamps are available.

Measure sufficiency and timestamp coverage

A valid report is not automatically a sufficient report. Inspect its dataSufficiency block:

import { analyzeGlucose, type GlucoseReading } from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:05:00Z' },
]
const report = analyzeGlucose(readings, {
  minDays: 14,
  minActivePercent: 70,
})

const {
  totalReadings,
  daysOfData,
  activePercent,
  meetsCGMStandard,
} = report.dataSufficiency

daysOfData is the span between the earliest and latest accepted readings. Its display value is rounded to one decimal place, while the minimum-days decision uses the unrounded span. activePercent is the percentage of expected five-minute timestamp slots occupied by at least one reading. meetsCGMStandard is true only when both configured thresholds are met, using the unrounded slot ratio for the coverage decision.

The report's active-percent calculation uses the default expected interval of five minutes. If the usual time between reading timestamps is not five minutes, calculate timestamp coverage separately:

import type { GlucoseReading } from '@glucoseiq/core'
import { calculateActivePercent } from '@glucoseiq/core/metrics'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:05:00Z' },
]
const coverage = calculateActivePercent(readings, {
  expectedIntervalMinutes: 1,
})

if (!coverage.meetsClinicalMinimum) {
  console.warn(`Coverage is ${coverage.activePercent}%`)
}

Slots are half-open and anchored to the earliest parseable timestamp. Exact duplicates and multiple readings inside one slot count once; actualReadings therefore reports occupied slots rather than raw input rows. Invalid timestamps are excluded. Fewer than two distinct parseable timestamps produce NaN coverage and fail the minimum-coverage flag.

This is a data-coverage estimate, not proof of sensor wear, sensor accuracy, or clinical suitability. Conflicting duplicate rows still participate separately in analytical percentages, so deduplicate by stable source identity before analysis when the same source record can arrive more than once. Normalized readings expose a dedupKey when the adapter can construct one.

Enhanced and pregnancy TIR summaries use the same five-minute slot model. summary.totalDuration is occupied-slot coverage, and summary.dataQuality requires at least 70% slot coverage before grading the observed span. Invalid or duplicate-only timestamps therefore produce zero summary duration and poor quality. Within each occupied slot, its five minutes are divided across the distinct observations and ranges represented in that slot. Exact duplicate observations count once, invalid timestamps receive no duration, and the final integer-minute allocation conserves the summary total. Percentages and reading counts still classify raw input rows.

Find gaps

Timestamp coverage is one aggregate number. detectGaps identifies the exact breaks a chart or review workflow may need to show:

import { detectGaps, type GlucoseReading } from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:30:00Z' },
]

const gaps = detectGaps(readings, { maxGapMinutes: 15 })

for (const gap of gaps) {
  console.log(gap.start, gap.end, gap.durationMinutes)
}

The function sorts valid timestamps chronologically and reports intervals strictly longer than maxGapMinutes. Invalid timestamps are ignored.

Choose the threshold based on the expected time between reading timestamps and how you will use the gaps. Connector capability descriptors list typical update and freshness intervals when you need a source-aware default:

import { detectGaps, type GlucoseReading } from '@glucoseiq/core'
import { DEXCOM_CAPABILITIES } from '@glucoseiq/core/connectors'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:30:00Z' },
]
const gaps = detectGaps(readings, {
  maxGapMinutes: DEXCOM_CAPABILITIES.maxFreshnessSec / 60,
})

Interpolate without rewriting history

alignToGrid creates a regular derived series for charting and time-aligned comparisons. It snaps observations to the nearest slot, interpolates only inside short bracketing gaps, and leaves longer gaps absent:

import { alignToGrid, type GlucoseReading } from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:10:00Z' },
]

const grid = alignToGrid(readings, {
  intervalMin: 5,
  maxInterpolateGapMin: 15,
  unit: 'mg/dL',
})

const observed = grid.filter((point) => !point.interpolated)
const inferred = grid.filter((point) => point.interpolated)

Each GridPoint contains timestamp, value, and interpolated; it is not a GlucoseReading because it does not carry a unit. Keep the selected output unit alongside the grid in your view model.

Treat aligned points as presentation or comparison data. Keep the original observations for reports unless your application chooses to count interpolated values as data.

Handle typed errors by code

Many validation and parsing paths throw a GlucoseIQError subclass with a stable code:

import {
  GlucoseIQError,
  buildAGPProfile,
  type GlucoseReading,
} from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
]
const selectedTimeZone = 'America/Detroit'
let feedback = ''

try {
  const profile = buildAGPProfile(readings, {
    timeZone: selectedTimeZone,
  })
} catch (error) {
  if (error instanceof GlucoseIQError) {
    switch (error.code) {
      case 'INVALID_TIMEZONE':
        feedback = 'Choose a valid IANA time zone.'
        break
      case 'INVALID_OPTION':
        feedback = error.message
        break
      default:
        throw error
    }
  } else {
    throw error
  }
}

console.info(feedback)

Other public helpers may return a sentinel result, skip malformed rows, or throw a standard JavaScript Error or RangeError. Follow the contract for the function you call.

On this page