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
| Need | API or result | Host responsibility |
|---|---|---|
| Skip one malformed vendor row | safeNormalize*Entries | Record or show the rejected-row count |
| Audit skipped CSV rows | parseGlucoseCSV | Compare source and accepted row counts when an audit is required |
| Check report validity | report.valid | Show an empty state for an invalid report |
| Measure available history | report.dataSufficiency or calculateActivePercent | Show coverage separately from analytical values |
| Find missing readings | detectGaps | Choose a threshold based on the expected time between reading timestamps |
| Build a regular chart grid | alignToGrid | Keep 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.dataSufficiencydaysOfData 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.