air-monitor
Version:
Utilities for working with air quality monitoring data.
306 lines (282 loc) • 12.3 kB
JavaScript
/**
* Utility functions for transforming and restructuring Monitor objects.
* These internal functions operate on the underlying `meta` and `data` tables
* and return plain `{ meta, data }` objects, enabling consistent post-processing.
*
* Internal functions:
* - `internal_collapse()` – Collapses multiple time series into a single column using aggregation.
* - `internal_combine()` – Merges two monitor objects, dropping duplicated IDs in the second.
* - `internal_select()` – Subsets a monitor by selected deviceDeploymentIDs.
* - `internal_filterByValue()` – Filters by a metadata field and matching value.
* - `internal_dropEmpty()` – Removes time series columns with no valid data.
* - `internal_trimDate()` – Trims time series to full local-time days.
*
* Helper functions:
* - `arrayMean()` – Computes the mean of an array, skipping null/NaN/invalid values.
* - `round1()` – Rounds all non-datetime columns in a data table to 1 decimal place.
*/
import * as aq from 'arquero';
const op = aq.op;
import { DateTime } from 'luxon';
import { arrayMean, round1 } from './helpers.js';
/**
* Collapse a Monitor object into a single time series.
*
* Collapses data from all time series into a single time series using the
* function provided in the `FUN` argument (typically 'mean'). The single time
* series result will be located at the mean longitude and latitude.
*
* When `FUN === "quantile"`, the `FUN_arg` argument specifies the quantile
* probability.
*
* Available function names are those defined at:
* https://uwdata.github.io/arquero/api/op#aggregate-functions
*
* @param {Monitor} monitor - The Monitor instance to collapse.
* @param {string} deviceID - The name of the resulting time series column.
* @param {string} FUN - The aggregate function name (e.g. "mean", "sum", "quantile").
* @param {number} FUN_arg - An optional argument for the aggregator (e.g. quantile prob).
* @returns {{ meta: aq.Table, data: aq.Table }} A Monitor object with a single time series.
*/
export function internal_collapse(monitor, deviceID = "generatedID", FUN = "mean", FUN_arg = 0.8) {
const meta = monitor.meta;
const data = monitor.data;
// ----- Create new_meta ---------------------------------------------------
const longitude = arrayMean(meta.array("longitude"));
const latitude = arrayMean(meta.array("latitude"));
// TODO: Could create new locationID based on geohash
const locationID = "xxx";
const deviceDeploymentID = `xxx_${deviceID}`;
// Start with first row and override key fields
let new_meta = meta.slice(0, 1).derive({
locationID: aq.escape(locationID),
locationName: aq.escape(deviceID),
longitude: aq.escape(longitude),
latitude: aq.escape(latitude),
elevation: aq.escape(null),
houseNumber: aq.escape(null),
street: aq.escape(null),
city: aq.escape(null),
zip: aq.escape(null),
deviceDeploymentID: aq.escape(deviceDeploymentID),
deviceType: aq.escape(null),
deploymentType: aq.escape(null)
});
// ----- Create new_data ---------------------------------------------------
// NOTE: Arquero provides no functionality for row-operations, nor for
// NOTE: transpose. So we have to perform the following operations:
// NOTE: - fold the data into a dataframe with timestamp and id columns
// NOTE: - pivot the data based on timestamp while summing data columns
// NOTE: - fold the result into a dataframe with timestamp and value columns
const ids = monitor.getIDs();
const datetime = monitor.getDatetime();
let transformed = data.derive({
utcDatestamp: 'd => op.format_utcdate(d.datetime)'
}).select(aq.not('datetime'));
const datetimeColumns = transformed.array('utcDatestamp');
// Build aggregation function string
let valueExpression;
if (FUN === 'count') {
valueExpression = '() => op.count()';
} else if (FUN === 'quantile') {
valueExpression = `d => op.quantile(d.value, ${FUN_arg})`;
} else {
valueExpression = `d => op.${FUN}(d.value)`;
}
const new_data = transformed.fold(ids).pivot({
key: 'd => d.utcDatestamp'
}, {
value: valueExpression
}).fold(datetimeColumns)
// NOTE: We don't use op.parse_date() because we require luxon DateTime
//.derive({ datetime: 'd => op.parse_date(d.key)' })
.derive({
datetime: aq.escape(d => DateTime.fromISO(d.key, {
zone: 'utc'
}))
}).rename({
value: deviceID
}).select(['datetime', deviceID]);
return {
meta: new_meta,
data: round1(new_data)
};
}
/**
* Combines two Monitor objects by merging their metadata and time series data.
* If any deviceDeploymentIDs in `monitorB` are already present in `monitorA`,
* they will be dropped from `monitorB` before merging.
*
* Data are combined with 'join_full' to guarantee that all times from either
* Monitor object will be retained.
*
* @param {Monitor} monitorA - The base Monitor instance.
* @param {Monitor} monitorB - The Monitor instance to merge in.
* @returns {{ meta: aq.Table, data: aq.Table }} A combined monitor object.
*/
export function internal_combine(monitorA, monitorB) {
const idsA = new Set(monitorA.meta.array('deviceDeploymentID'));
const idsB = monitorB.meta.array('deviceDeploymentID');
// Identify overlapping and unique IDs
const overlappingIDs = idsB.filter(id => idsA.has(id));
const uniqueIDs = idsB.filter(id => !idsA.has(id));
// Filter monitorB's meta and data to include only unique IDs
const metaB = monitorB.meta.params({
ids: uniqueIDs
}).filter('op.includes(ids, d.deviceDeploymentID)');
const dataB = monitorB.data.select(['datetime', ...uniqueIDs]);
// Combine everything
const combinedMeta = monitorA.meta.concat(metaB);
const combinedData = monitorA.data.join_full(dataB, "datetime").orderby("datetime");
return {
meta: combinedMeta,
data: round1(combinedData)
};
}
/**
* Subsets and reorders time series columns and corresponding metadata
* for the specified deviceDeploymentIDs.
*
* Ensures that the returned `meta` rows appear in the same order as `ids`,
* and that all specified columns are included in the `data` table.
*
* @param {Monitor} monitor - The Monitor instance containing metadata and data.
* @param {string[]} ids - An array of deviceDeploymentIDs to select and order.
* @returns {{ meta: aq.Table, data: aq.Table }} A subset of the monitor with selected columns.
*
* @throws {Error} If `ids` is not a non-empty array.
*/
export function internal_select(monitor, ids) {
// Normalize to array if a single string is passed
if (typeof ids === 'string') {
ids = [ids];
}
if (!Array.isArray(ids) || ids.length === 0) {
throw new Error('ids must be a non-empty string or array of deviceDeploymentIDs');
}
if (new Set(ids).size !== ids.length) {
throw new Error('Duplicate deviceDeploymentID values are not allowed in select()');
}
// Reorder meta rows to match the order of `ids`
const metaRows = ids.map(id => {
const row = monitor.meta.objects().find(r => r.deviceDeploymentID === id);
if (!row) {
throw new Error(`deviceDeploymentID '${id}' not found in metadata`);
}
return row;
});
const meta = aq.from(metaRows);
// Subset and reorder columns in the data table
const data = monitor.data.select(['datetime', ...ids]);
return {
meta: meta,
data: round1(data)
};
}
/**
* Filters a monitor object to include only records where a given metadata field equals the specified value.
*
* @param {Monitor} monitor - The Monitor instance containing metadata and data.
* @param {string} columnName - Name of the metadata column to filter on.
* @param {string|number} value - Value to match in the specified column.
* @returns {{ meta: aq.Table, data: aq.Table }} A filtered monitor object.
*
* @throws {Error} If the specified column does not exist in monitor.meta.
*/
export function internal_filterByValue(monitor, columnName, value) {
if (!monitor.meta.columnNames().includes(columnName)) {
throw new Error(`Column '${columnName}' not found in metadata`);
}
const colType = typeof monitor.meta.get(columnName);
let filterExpression;
if (colType === 'number') {
const parsedValue = parseFloat(value);
if (isNaN(parsedValue)) {
throw new Error(`Value '${value}' could not be parsed as a number`);
}
filterExpression = `d => op.equal(d.${columnName}, ${parsedValue})`;
} else if (colType === 'string') {
const escaped = value.toString().replace(/'/g, "\\'");
filterExpression = `d => op.equal(d.${columnName}, '${escaped}')`;
} else {
throw new Error(`Unsupported column type for filtering: ${colType}`);
}
const meta = monitor.meta.filter(filterExpression);
const ids = meta.array('deviceDeploymentID');
const data = monitor.data.select(['datetime', ...ids]);
return {
meta: meta,
data: round1(data)
};
}
/**
* Drops time series from the monitor that contain only missing values.
*
* A value is considered missing if it is null, undefined, NaN, or an invalid string (e.g. 'NA').
* The resulting monitor object includes only the deviceDeploymentIDs with at least one valid observation.
*
* @param {Monitor} monitor - The Monitor instance containing metadata and data.
* @returns {{ meta: aq.Table, data: aq.Table }} A new monitor object with empty time series removed.
*/
export function internal_dropEmpty(monitor) {
const data = monitor.data;
const ids = data.columnNames().filter(c => c !== 'datetime');
// Count valid (non-null, non-NaN, non-'NA') values for each time series column
const countRow = data
// arquero pattern to compute column-wise aggregations
.rollup(Object.fromEntries(ids.map(id => [id, "d => op.valid(d['" + id + "'])"]))).object(0); // Get the single row as an object
// Keep only the IDs with at least one valid value
const validIDs = Object.entries(countRow).filter(([_, count]) => count > 0).map(([id]) => id);
const filteredData = data.select(['datetime', ...validIDs]);
const filteredMeta = monitor.meta.params({
ids: validIDs
}).filter((d, $) => op.includes($.ids, d.deviceDeploymentID));
return {
meta: filteredMeta,
data: round1(filteredData)
};
}
/**
* Trims time-series data to full local-time days (00:00–23:00),
* and optionally removes full days with no data at the start or end.
*
* @param {Monitor} monitor - The Monitor instance with datetime-sorted, hourly-interval data.
* @param {string} timezone - An IANA timezone string (e.g., "America/New_York").
* @param {boolean} [trimEmptyDays=true] - Whether to remove fully-missing days at edges.
* @returns {{ meta: aq.Table, data: aq.Table }} A subset of the monitor with trimmed data.
*
* @throws {Error} If the datetime column is missing, empty, or timezone is invalid.
*/
export function internal_trimDate(monitor, timezone, trimEmptyDays = true) {
const datetime = monitor.data.array('datetime');
if (!datetime || datetime.length === 0) {
throw new Error('No datetime values found in monitor.data');
}
// Validate timezone
const test = datetime[0].setZone(timezone);
if (!test.isValid || test.zoneName !== timezone) {
throw new Error(`Invalid or unrecognized timezone: '${timezone}'`);
}
// Convert first and last timestamps to local time
const startLocal = datetime[0].setZone(timezone);
const endLocal = datetime[datetime.length - 1].setZone(timezone);
// Compute number of hours to trim at start and end
const startTrim = startLocal.hour === 0 ? 0 : 24 - startLocal.hour;
const endTrim = endLocal.hour === 23 ? 0 : endLocal.hour + 1;
let start = startTrim;
let end = datetime.length - endTrim;
if (trimEmptyDays) {
const dataCols = monitor.data.columnNames().filter(c => c !== 'datetime');
const firstDay = monitor.data.slice(start, start + 24);
const allInvalidStart = dataCols.every(col => firstDay.array(col).every(v => v == null));
if (allInvalidStart) start += 24;
const lastDay = monitor.data.slice(end - 24, end);
const allInvalidEnd = dataCols.every(col => lastDay.array(col).every(v => v == null));
if (allInvalidEnd) end -= 24;
}
const trimmed = monitor.data.slice(start, end);
return {
meta: monitor.meta,
data: round1(trimmed)
};
}