import { DateTime } from 'luxon';
import Monitor from '../index.js';
/**
* Internal utility functions for data transformation and cleanup in the `air-monitor` package.
* These helpers are used across modules to support scientific data processing workflows.
*
* - `arrayMean()`: Computes the arithmetic mean of valid numeric entries in an array.
* - `round1()`: Rounds numeric measurement columns in an Arquero table to 1 decimal place.
* - `validateDeviceID()`: Verifies that a single ID exists in the monitor object.
* - `assertIsMonitor()`: Asserts that the value is a Monitor instance.
* - `assert()`: Browser-safe assert function.
* - `validateDataTable()`: Validates a Monitor data table.
* - `parseDatetime()`: Parse a user-provided datetime into a Luxon DateTime.
*
* All functions in this file are pure and side-effect-free.
* Intended for internal use within the package.
*/
/**
* Computes the arithmetic mean of an array of numbers, ignoring non-numeric values.
*
* @param {Array<*>} arr - The input array (may contain nulls, strings, or other types).
* @returns {number|null} The mean of valid numbers, or null if none are found.
*/
export function arrayMean(arr) {
const valid = arr.filter(v => typeof v === 'number' && Number.isFinite(v));
const sum = valid.reduce((acc, v) => acc + v, 0);
return valid.length > 0 ? sum / valid.length : null;
}
/**
* round1
*
* Round all numeric columns (except 'datetime') to 1 decimal place.
* Converts non-finite values (NaN, Infinity, undefined) to `null`.
*
* @param {aq.Table} table - Input table with 'datetime' as the first column.
* @returns {aq.Table} - New table with rounded numeric values and nulls.
*/
export function round1(table) {
const columns = table.columnNames().filter(name => name !== 'datetime');
const expressions = Object.fromEntries(
columns.map(col => [
col,
// Wrap in finite check — if not finite, return null
`d => op.is_finite(d['${col}']) ? op.round(d['${col}'] * 10) / 10 : null`
])
);
return table.derive(expressions);
}
/**
* Validates and resolves a deviceDeploymentID.
* Accepts either a string or a single-element string array.
* Verifies that the resolved ID exists in monitor.data.
*
* @param {Monitor} monitor - The Monitor instance containing time-series data.
* @param {string | string[]} id - A single string or a single-element array.
* @returns {string} A validated deviceDeploymentID string.
*
* @throws {Error} If input is invalid or the ID does not exist in monitor.data.
*/
export function validateDeviceID(monitor, id) {
let deviceID;
if (typeof id === 'string') {
deviceID = id;
} else if (Array.isArray(id) && id.length === 1 && typeof id[0] === 'string') {
deviceID = id[0];
} else {
throw new Error(
`Expected deviceDeploymentID to be a string or a single-element string array. Received: ${JSON.stringify(id)}`
);
}
const availableIDs = monitor.data.columnNames();
if (!availableIDs.includes(deviceID)) {
throw new Error(`Device ID '${deviceID}' not found in monitor.data`);
}
return deviceID;
}
/**
* Asserts that the value is a Monitor instance.
* Useful for verifying return types of public methods.
* @param {*} result - The value to check.
* @param {string} methodName - Name of the method returning the value (for error messages).
*/
export function assertIsMonitor(result, methodName = 'unknown') {
if (!(result instanceof Monitor)) {
throw new Error(`${methodName}() must return a Monitor instance`);
}
}
/**
* Browser-safe assert function.
* Throws an Error if the condition is false.
*/
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/**
* Validates a Monitor data table with the following requirements:
* - Must contain a 'datetime' column with only valid Luxon UTC DateTime objects
* - Datetimes must be in strictly increasing order, spaced exactly 1 hour apart
* - All other columns must be numeric
*
* @param {aq.Table} table - The Arquero table to validate.
* @throws {Error} If validation fails.
*/
export function validateDataTable(table) {
assert(table.columnNames().includes('datetime'), `'datetime' column is missing`);
const datetimes = table.array('datetime');
const n = datetimes.length;
for (let i = 0; i < n; i++) {
const dt = datetimes[i];
assert(DateTime.isDateTime(dt), `Row ${i}: datetime is not a Luxon DateTime`);
assert(dt.isValid, `Row ${i}: datetime is invalid: ${dt.invalidReason}`);
assert(dt.zoneName === 'UTC', `Row ${i}: datetime is not in UTC`);
}
for (let i = 1; i < n; i++) {
const prev = datetimes[i - 1];
const curr = datetimes[i];
const diff = curr.diff(prev, 'hours').hours;
assert(diff === 1, `Row ${i}: datetime gap is ${diff} hours (expected 1 hour)`);
}
for (const col of table.columnNames()) {
if (col === 'datetime') continue;
const colData = table.array(col);
for (let i = 0; i < colData.length; i++) {
const val = colData[i];
const isValid =
val === null ||
(typeof val === 'number' && Number.isFinite(val));
assert(isValid, `Row ${i}, column '${col}': value is not numeric or null`);
}
}
}
/**
* Parse a user-provided datetime into a Luxon DateTime.
*
* Accepted inputs:
* - A Luxon DateTime object → returned unchanged.
* - A native JS Date → interpreted in the supplied `timezone`.
* - A string → parsed using ISO, SQL, or "yyyy-MM-dd" formats,
* interpreted in the supplied `timezone`.
*
* Requirements:
* - If `value` is NOT a Luxon DateTime, then a valid `timezone`
* (IANA string) MUST be provided.
*
* Behavior:
* - If `isEnd === true` and the input is a date-only string (e.g. "2025-02-10"),
* the returned DateTime is promoted to the *end of that day* in the
* given timezone (23:59:59.999).
*
* Note:
* - This function does NOT convert to UTC. Conversions should be performed
* by the caller (e.g., via `.toUTC()`), since different utilities may
* require local-time or UTC interpretations depending on context.
*
* @param {DateTime | string | Date} value
* The datetime input to parse.
*
* @param {string} [timezone]
* Required for string and native Date inputs.
* Must be a valid IANA timezone (e.g., "America/Los_Angeles").
*
* @param {boolean} [isEnd=false]
* If true and the input is a date-only string, expand to end-of-day.
*
* @returns {DateTime}
* A Luxon DateTime in the interpreted timezone.
*
* @throws {Error}
* If timezone is missing when required, or if parsing fails.
*/
export function parseDatetime(value, timezone, isEnd = false) {
// Case 1 — already a Luxon DateTime
if (DateTime.isDateTime(value)) {
return value;
}
// All other types require a timezone
if (!timezone) {
throw new Error(
'A timezone must be provided when parsing non-Luxon datetime inputs.'
);
}
// Validate timezone before parsing
const test = DateTime.now().setZone(timezone);
if (!test.isValid || test.zoneName !== timezone) {
throw new Error(`Invalid or unrecognized timezone: '${timezone}'`);
}
let dt;
// Case 2 — native JS Date
if (value instanceof Date) {
dt = DateTime.fromJSDate(value, { zone: timezone });
}
// Case 3 — strings
else if (typeof value === 'string') {
// Try ISO: "2024-01-01", "2024-01-01T12:30"
dt = DateTime.fromISO(value, { zone: timezone });
// Try SQL: "2024-01-01 12:30:00"
if (!dt.isValid) {
dt = DateTime.fromSQL(value, { zone: timezone });
}
// Try explicit date-only format: "yyyy-MM-dd"
if (!dt.isValid) {
dt = DateTime.fromFormat(value, 'yyyy-MM-dd', { zone: timezone });
}
if (!dt.isValid) {
throw new Error(`Could not parse datetime string '${value}'.`);
}
// Promote date-only strings to end-of-day when requested
const isDateOnly = !/[T\s]/.test(value);
if (isEnd && isDateOnly) {
dt = dt.endOf('day');
}
}
else {
throw new Error(
`Unsupported datetime input type: ${typeof value}. Expected DateTime, Date, or string.`
);
}
return dt;
}