agmission/server/scripts/migrate_applications.js

1272 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict';
/**
* migrate_applications.js
*
* Merged replacement for:
* - scripts/backfill_application_datetimes.js
* - scripts/migrate_app_aggregates.js
*
* What it does:
* Pass 1 — Per-application streaming pass (aggregates + datetime in a single AppDetail scan)
* A. Aggregate metrics:
* Application.avgSpraySpeed — weighted avg ground speed (m/s) across spray-on records
* Application.totalSprLength — cumulative geodesic spray distance (m)
* Application.totalFlightLength — cumulative flight distance (m)
* Application.avgXtError — weighted avg abs cross-track error (m) over spray-on records
* Application.avgHdop — weighted avg HDOP over spray-on records
* AppFile.totalSprLength — per-file spray distance (m)
* AppFile.totalFlightLength — per-file flight distance (m)
* B. Datetime fields (only when app.startDateTime exists AND a lat/lon coordinate is found):
* Application.utcOffset — UTC offset in minutes for the mission location
* Application.startDateTimeUTC — UTC wall-clock start time
* Application.endDateTimeUTC — UTC wall-clock end time
*
* Pass 2 — Application-level flowAccuracyPct (no AppDetail needed):
* Application.flowAccuracyPct — (totalSprayMat/totalSprayed / appRate) × 100
*
* Merge optimisation:
* In the default (aggregates) mode the script already streams all AppDetail records for each
* file sorted by gpsTime ASC. The first valid lat/lon encountered during that stream is
* captured as firstLat/firstLon and reused for datetime computation — eliminating the separate
* findOne query that the old backfill script required.
*
* ─── Usage ────────────────────────────────────────────────────────────────────
*
* # Full migration (aggregates + datetime), newest apps first
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js
*
* # Tier 1 — most recent 90 days first
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --tier-days=90
*
* # Tier 2 — everything from 2023 onward
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --from-date=2023-01-01
*
* # Dry-run (logs what would change, no writes)
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --dry-run --tier-days=7
*
* # Recompute already-set fields (e.g. after algorithm fix)
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --force --tier-days=30
*
* # One-time repair for xTrack decode change — run in this exact order:
* # Step 1 (with OLD server still running): fix AppDetail raw cm → metres, unset avgXtError
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --repair-agn-xt
* # Step 2: restart the server with the new code
* # Step 3 (with NEW server running): recompute Application.avgXtError
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js
*
* # Datetime fields only (skip aggregate streaming — uses lightweight findOne)
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --skip-aggregates
*
* # Aggregate fields only (skip datetime computation)
* DEBUG=agm:migrate-applications node scripts/migrate_applications.js --skip-datetime
*
* # Custom env file
* node scripts/migrate_applications.js --env ./environment_prod.env --tier-days=90
*
* ─── Options ──────────────────────────────────────────────────────────────────
*
* --env <path> Environment file path (default: ./environment.env)
* --dry-run Report only; make no DB writes
* --force Recompute even when fields are already set
* --batch-size=N Applications per bulkWrite flush (default: 50)
* --tier-days=N Only process apps created in the last N days
* --from-date=YYYY-MM-DD Only process apps created on/after this date (UTC)
* --concurrency=N Parallel file-level cursors per application (default: 3)
* --missing-limit=N Max apps to list in the missing-legacy-datetime report (default: 100)
* --skip-datetime Skip datetime backfill (pass 1B); still runs aggregates + pass 2
* --skip-aggregates Skip aggregate metrics and flowAccuracyPct (passes 1A + 2);
* uses a lightweight findOne for datetime instead of full streaming
* --repair-agn-xt One-time fix: identifies LQD AgNav binary AppFiles (.nt extension,
* non-DRY), multiplies their AppDetail.xTrack by 0.01 (raw cm → m),
* clears avgXtError on those apps, then implies --force for recompute.
* Run ONCE before restarting the server with the updated code.
*
* ─── Fields updated ───────────────────────────────────────────────────────────
*
* A. Datetime fields (Application):
* utcOffset, startDateTimeUTC, endDateTimeUTC
*
* B. Aggregate fields:
* Application: avgSpraySpeed, totalSprLength, totalFlightLength,
* avgXtError, avgHdop, flowAccuracyPct
* AppFile: totalSprLength, totalFlightLength
*/
// ─── Environment bootstrap ────────────────────────────────────────────────────
const path = require('path');
const args = process.argv.slice(2);
let envFile = './environment.env';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--env' && args[i + 1]) { envFile = args[i + 1]; i++; }
else if (args[i].startsWith('--env=')) { envFile = args[i].split('=')[1]; }
}
require('dotenv').config({ path: path.resolve(process.cwd(), envFile) });
// ─── Imports (after env is loaded) ───────────────────────────────────────────
const debug = require('debug')('agm:migrate-applications');
const mongoose = require('mongoose');
const { DBConnection } = require('../helpers/db/connect');
const Application = require('../model/application');
const AppFile = require('../model/application_file');
const AppDetail = require('../model/application_detail');
const { App, Job } = require('../model/index.js');
const appDateTime = require('../helpers/application_datetime');
const { RecTypes } = require('../helpers/work_record');
const { rateInfoFromFileMeta } = require('../helpers/utils');
// ─── Argument parsing ─────────────────────────────────────────────────────────
const cfg = {
dryRun: false,
force: false,
repairAgnXt: false, // One-time: fix LQD AgNav binary AppDetail.xTrack from raw cm to metres
revertRepairAgnXt: false, // One-time: reverse a partial --repair-agn-xt run (× 100) for first N files
revertRepairN: 0, // Number of LQD files to revert (from the aborted repair run)
fixDecodedXt: false, // One-time: fix AppDetail.xTrack wrongly decoded by _readAgnBinary (× 100 non-integers)
fixXtApps: false, // One-time: unset avgXtError on LQD NT apps where value is in impossible range (0, 1.0)
batchSize: 50,
tierDays: null, // Number — only apps newer than N days
fromDate: null, // String — ISO date, only apps on/after this date
concurrency: 3, // parallel file cursors per app
missingLimit: 100, // max apps to show in missing-legacy-datetime report
skipDatetime: false, // skip datetime computation (pass 1B)
skipAggregates: false, // skip aggregates + flowAccuracyPct (passes 1A + 2)
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--dry-run') cfg.dryRun = true;
else if (a === '--force') cfg.force = true;
else if (a === '--repair-agn-xt') cfg.repairAgnXt = true;
else if (a.startsWith('--revert-repair-agn-xt=')) { cfg.revertRepairAgnXt = true; cfg.revertRepairN = parseInt(a.split('=')[1], 10) || 0; }
else if (a === '--fix-decoded-xt') cfg.fixDecodedXt = true;
else if (a === '--fix-xt-apps') cfg.fixXtApps = true;
else if (a === '--skip-datetime') cfg.skipDatetime = true;
else if (a === '--skip-aggregates') cfg.skipAggregates = true;
else if (a.startsWith('--batch-size=')) cfg.batchSize = parseInt(a.split('=')[1], 10) || 50;
else if (a.startsWith('--tier-days=')) cfg.tierDays = parseInt(a.split('=')[1], 10) || null;
else if (a.startsWith('--from-date=')) cfg.fromDate = a.split('=')[1] || null;
else if (a.startsWith('--concurrency=')) cfg.concurrency = parseInt(a.split('=')[1], 10) || 3;
else if (a === '--missing-limit' && args[i + 1]) {
const parsed = parseInt(args[i + 1], 10);
if (!Number.isNaN(parsed) && parsed > 0) cfg.missingLimit = parsed;
i++;
} else if (a.startsWith('--missing-limit=')) {
const parsed = parseInt(a.split('=')[1], 10);
if (!Number.isNaN(parsed) && parsed > 0) cfg.missingLimit = parsed;
}
// --env already consumed above
}
// ─── Constants ────────────────────────────────────────────────────────────────
const MAX_SEGMENT_METERS = 1000; // Sanity cap matching job_worker readSatLogAsc
const PROGRESS_LOG_INTERVAL = 100; // Log a line every N apps
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Fast Haversine distance in metres between two WGS-84 points.
* Avoids turf object allocation in tight loops.
*/
function haversineMeters(lon1, lat1, lon2, lat2) {
const R = 6371000;
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Create a minimal ObjectId whose timestamp equals `date`.
*/
function objectIdFromDate(date) {
const ts = Math.floor(new Date(date).getTime() / 1000);
const hex = ts.toString(16).padStart(8, '0') + '0000000000000000';
return new mongoose.Types.ObjectId(hex);
}
// ─── AppDetail helpers ────────────────────────────────────────────────────────
/**
* Lightweight single-record lookup used in --skip-aggregates mode.
* Returns the first AppDetail record with a valid lat/lon, sorted by gpsTime ASC.
* Mirrors getReferenceDetail from backfill_application_datetimes.js.
*/
async function getReferenceDetail(appId) {
const files = await AppFile.find({ appId, markedDelete: { $ne: true } }, { _id: 1 }).lean();
const fileIds = files.map(f => f._id);
if (fileIds.length) {
return AppDetail.findOne({
fileId: { $in: fileIds },
lat: { $type: 'number' },
lon: { $type: 'number' }
}).sort({ gpsTime: 1, _id: 1 }).lean();
}
return AppDetail.findOne({
appId,
lat: { $type: 'number' },
lon: { $type: 'number' }
}).sort({ gpsTime: 1, _id: 1 }).lean();
}
/**
* Convert an AppFile.meta application rate to metric (L/ha or Kg/ha).
* Mirrors the unit conversion in job_worker.js::getAppliedRate().
* RateUnit codes: 0=oz/acre, 1=gal/acre, 2=lbs/acre, 3=L/ha, 4=Kg/ha
*/
function metricAppRateFromMeta(meta) {
const rate = meta.appRate;
if (!rate || rate <= 0) return 0;
let unit = typeof meta.rateUnit === 'number' ? meta.rateUnit : -1;
if (unit < 0 && typeof meta.appRateUnitStr === 'string') {
const s = meta.appRateUnitStr.toLowerCase();
if (s.includes('oz')) unit = 0;
else if (s.includes('gal')) unit = 1;
else if (s.includes('lb')) unit = 2;
else if (s.includes('l/ha') || s.includes('lit')) unit = 3;
else if (s.includes('kg')) unit = 4;
}
if (unit === 0) return rate * 0.0730778; // oz/acre → L/ha
if (unit === 1) return rate * 9.35396; // gal/acre → L/ha
if (unit === 2) return rate * 1.12085; // lbs/acre → Kg/ha
return rate; // L/ha or Kg/ha — already metric
}
// ─── One-time LQD AgNav xTrack repair ────────────────────────────────────────
/**
* One-time repair: convert AppDetail.xTrack from raw cm to metres for AgNav
* LQD binary files.
*
* Background: AppDetail.xTrack has been inconsistent across file types:
* - AgNav binary LQD (.nt): raw cm (integer) — needs ÷ 100
* - AgNav binary DRY (.nt): already metres (AMS DRY decode × 1E-2 + merge)
* - SatLoc ASCII (.asc): already metres (X-Track field is in metres)
* - AgNav Shape (.shp): already metres (XTRACK field is in metres)
*
* The fix in work_record.js now decodes _readAgnBinary xTrack cm → m so all
* types are consistent metres. This function converts the existing raw-cm
* AppDetail records for LQD AgNav binary files to match.
*
* Run ONCE before restarting the server with the updated code.
* LQD binary files are identified by:
* - file name ending in .nt (case-insensitive)
* - rateInfoFromFileMeta does NOT return AGN_BIN_DRY
*/
async function repairAgnXtData() {
debug('repair-agn-xt: scanning AppFiles for LQD AgNav binary type…');
// AgNav NT binary: nYMMDDHH.tMM or nYMMDDHH-N.tMM (e.g. n6021809.t15)
const RE_AGN_NT = /^n\d{7}(-\d+)?\.t\d{2}$/i;
const lqdFileIds = [];
let totalScanned = 0, skippedNoName = 0, skippedExtension = 0, skippedDry = 0;
const fileCursor = AppFile.find().select('_id name meta').lean().cursor();
for await (const f of fileCursor) {
totalScanned++;
if (!f.name) { skippedNoName++; continue; }
const base = require('path').basename(f.name);
if (!RE_AGN_NT.test(base)) { skippedExtension++; continue; }
// Skip DRY NT: AMS DRY decode already stored metres
const rateInfo = rateInfoFromFileMeta(f.meta || {}, RecTypes.AGN_BIN_LQD);
if (rateInfo.recType === RecTypes.AGN_BIN_DRY) { skippedDry++; continue; }
lqdFileIds.push(f._id);
}
debug(`repair-agn-xt: scanned ${totalScanned} — noName:${skippedNoName} other:${skippedExtension} dry:${skippedDry} lqd-nt:${lqdFileIds.length}`);
if (!lqdFileIds.length) {
debug('repair-agn-xt: no LQD AgNav binary AppFiles found — nothing to repair');
return;
}
const BATCH = 500;
let totalRepaired = 0, totalApps = new Set();
if (cfg.dryRun) {
let dryCount = 0;
for (let i = 0; i < lqdFileIds.length; i += BATCH) {
const batch = lqdFileIds.slice(i, i + BATCH);
dryCount += await AppDetail.countDocuments({ fileId: { $in: batch }, xTrack: { $ne: 0 } });
}
debug(`[dry-run] repair-agn-xt: would repair ${dryCount} AppDetail xTrack record(s) (× 1E-2)`);
} else {
for (let i = 0; i < lqdFileIds.length; i += BATCH) {
const batch = lqdFileIds.slice(i, i + BATCH);
const result = await AppDetail.updateMany(
{ fileId: { $in: batch }, xTrack: { $ne: 0 } },
[{ $set: { xTrack: { $multiply: ['$xTrack', 0.01] } } }]
);
totalRepaired += result.modifiedCount;
const appIds = await AppFile.distinct('appId', { _id: { $in: batch } });
appIds.forEach(id => totalApps.add(String(id)));
if ((i / BATCH) % 20 === 0)
debug(`repair-agn-xt: progress ${i + batch.length}/${lqdFileIds.length} files, ${totalRepaired} records repaired so far`);
}
debug(`repair-agn-xt: repaired ${totalRepaired} AppDetail xTrack record(s)`);
// Unset avgXtError on affected apps so plain migration recomputes them
const appIdArr = [...totalApps].map(id => require('mongoose').Types.ObjectId(id));
if (appIdArr.length) {
await Application.updateMany(
{ _id: { $in: appIdArr } },
{ $unset: { avgXtError: 1 } }
);
debug(`repair-agn-xt: unset avgXtError on ${appIdArr.length} Application(s) — run migration to recompute`);
}
}
}
/**
* Reverse a partial --repair-agn-xt run.
*
* The repair mistakenly multiplied FlightData.xtrack (already metres) by 0.01.
* This function scans LQD NT AppFiles in natural order (same as repairAgnXtData),
* takes the first N files, and multiplies their AppDetail.xTrack by 100 to restore
* the original integer-metres values. Also unsets avgXtError so migration recomputes.
*
* Usage: --revert-repair-agn-xt=170500
*/
async function revertRepairAgnXtData(n) {
debug(`revert-repair-agn-xt: scanning to collect first ${n} LQD NT AppFiles (same order as repair)…`);
const RE_AGN_NT = /^n\d{7}(-\d+)?\.t\d{2}$/i;
const targetIds = [];
const fileCursor = AppFile.find().select('_id name meta').lean().cursor();
for await (const f of fileCursor) {
if (targetIds.length >= n) break;
if (!f.name) continue;
const base = require('path').basename(f.name);
if (!RE_AGN_NT.test(base)) continue;
const rateInfo = rateInfoFromFileMeta(f.meta || {}, RecTypes.AGN_BIN_LQD);
if (rateInfo.recType === RecTypes.AGN_BIN_DRY) continue;
targetIds.push(f._id);
}
debug(`revert-repair-agn-xt: reverting ${targetIds.length} files (× 100)…`);
const BATCH = 500;
let totalReverted = 0;
const totalApps = new Set();
for (let i = 0; i < targetIds.length; i += BATCH) {
const batch = targetIds.slice(i, i + BATCH);
const result = await AppDetail.updateMany(
{ fileId: { $in: batch }, xTrack: { $ne: 0 } },
{ $mul: { xTrack: 100 } }
);
totalReverted += result.modifiedCount;
if (result.modifiedCount) {
const appIds = await AppFile.distinct('appId', { _id: { $in: batch } });
appIds.forEach(id => totalApps.add(String(id)));
}
if ((i / BATCH) % 20 === 0)
debug(`revert-repair-agn-xt: progress ${i + batch.length}/${targetIds.length} files, ${totalReverted} records restored`);
}
debug(`revert-repair-agn-xt: restored ${totalReverted} AppDetail xTrack record(s)`);
const appIdArr = [...totalApps].map(id => require('mongoose').Types.ObjectId(id));
if (appIdArr.length) {
await Application.updateMany(
{ _id: { $in: appIdArr } },
{ $unset: { avgXtError: 1 } }
);
debug(`revert-repair-agn-xt: unset avgXtError on ${appIdArr.length} Application(s) — run migration to recompute`);
}
}
/**
* Fix AppDetail.xTrack records that were wrongly decoded by the brief _readAgnBinary
* bug (which applied × 1E-2 to FlightData.xtrack, which is already integer metres).
*
* Detection: FlightData.xtrack is always a rounded integer. Any non-integer xTrack
* value for a LQD NT file is definitively a wrongly-decoded record (× 0.01 applied).
* Multiplying back by 100 restores the original integer-metres value.
*
* Safe to run after --revert-repair-agn-xt — the revert restores old records to
* integers, so only newly-uploaded wrongly-decoded records remain as decimals.
*/
async function fixDecodedXtData() {
debug('fix-decoded-xt: scanning LQD NT AppFiles for wrongly-decoded xTrack (non-integer)…');
const RE_AGN_NT = /^n\d{7}(-\d+)?\.t\d{2}$/i;
const lqdFileIds = [];
const fileCursor = AppFile.find().select('_id name meta').lean().cursor();
for await (const f of fileCursor) {
if (!f.name) continue;
const base = require('path').basename(f.name);
if (!RE_AGN_NT.test(base)) continue;
const rateInfo = rateInfoFromFileMeta(f.meta || {}, RecTypes.AGN_BIN_LQD);
if (rateInfo.recType === RecTypes.AGN_BIN_DRY) continue;
lqdFileIds.push(f._id);
}
debug(`fix-decoded-xt: found ${lqdFileIds.length} LQD NT files — checking for non-integer xTrack…`);
// Non-integer xTrack → was decoded with the wrong × 0.01 → multiply back by 100.
//
// Detection via aggregation pipeline update with $floor:
// { $ne: ['$xTrack', { $floor: '$xTrack' }] } → true when xTrack has a fractional part
//
// Why NOT $mod query operator: MongoDB's $mod TRUNCATES floats to int before computing,
// so { $mod: [1, 0] } matches ALL numbers (trunc(6.89) % 1 = 6 % 1 = 0). This silently
// makes $nor: [{ $mod:[1,0] }] find nothing. Discovered after 4512 apps still had wrong
// avgXtError despite "fix" reporting zero records (2026-06-23).
//
// Why NOT $expr in the filter: $expr prevents fileId index pushdown on 1.5B docs → DB crash.
//
// Safe approach: simple filter { fileId: { $in: batch } } for index pushdown, then
// aggregation pipeline update with $cond+$floor to selectively multiply fractional values.
// The pipeline runs AFTER index lookup — no index interference.
const BATCH = 500;
let totalFixed = 0;
const totalApps = new Set();
for (let i = 0; i < lqdFileIds.length; i += BATCH) {
const batch = lqdFileIds.slice(i, i + BATCH);
if (cfg.dryRun) {
// No AppDetail queries in dry-run — any count/find on 1.5B docs risks server load.
} else {
// Pipeline update: only modifies records where xTrack ≠ floor(xTrack) (fractional).
// Integer values (floor(n) == n) are left unchanged by the else branch.
const result = await AppDetail.updateMany(
{ fileId: { $in: batch } },
[{
$set: {
xTrack: {
$cond: {
if: { $and: [
{ $ne: ['$xTrack', 0] },
{ $ne: ['$xTrack', { $floor: '$xTrack' }] }
]},
then: { $multiply: ['$xTrack', 100] },
else: '$xTrack'
}
}
}
}]
);
if (result.modifiedCount) {
totalFixed += result.modifiedCount;
const appIds = await AppFile.distinct('appId', { _id: { $in: batch } });
appIds.forEach(id => totalApps.add(String(id)));
}
}
if ((i / BATCH) % 20 === 0)
debug(`fix-decoded-xt: progress ${i + batch.length}/${lqdFileIds.length} files${cfg.dryRun ? '' : `, ${totalFixed} records fixed so far`}`);
}
if (cfg.dryRun) {
debug(`fix-decoded-xt: [dry-run] scanned ${lqdFileIds.length} LQD NT files — run without --dry-run to apply fix`);
return;
}
debug(`fix-decoded-xt: fixed ${totalFixed} AppDetail xTrack record(s)`);
if (totalApps.size) {
const appIdArr = [...totalApps].map(id => require('mongoose').Types.ObjectId(id));
await Application.updateMany(
{ _id: { $in: appIdArr } },
{ $unset: { avgXtError: 1 } }
);
debug(`fix-decoded-xt: unset avgXtError on ${appIdArr.length} Application(s) — run migration to recompute`);
} else {
debug('fix-decoded-xt: no wrongly-decoded records found — nothing to fix');
}
}
/**
* Unset avgXtError on LQD NT applications where its value is in the impossible
* range (0, 1.0) — which is the wrong-decode signature at the application level.
*
* FlightData.xtrack is integer metres, so the minimum meaningful avgXtError for
* an LQD NT app is 1.0 m. Any value in (0, 1.0) was computed from fractional
* AppDetail.xTrack values (wrong × 0.01 decode). After unsetting, run migration
* to recompute from the now-corrected AppDetail records.
*
* DRY apps are explicitly excluded — AMS xTrack has 0.01 m precision so small
* avgXtError values are valid there.
*
* Usage: --fix-xt-apps
*/
async function fixXtApps() {
debug('fix-xt-apps: scanning LQD NT AppFiles to collect app IDs…');
const RE_AGN_NT = /^n\d{7}(-\d+)?\.t\d{2}$/i;
const appIdSet = new Set();
const fileCursor = AppFile.find().select('_id name meta appId').lean().cursor();
for await (const f of fileCursor) {
if (!f.name || !f.appId) continue;
const base = require('path').basename(f.name);
if (!RE_AGN_NT.test(base)) continue;
const rateInfo = rateInfoFromFileMeta(f.meta || {}, RecTypes.AGN_BIN_LQD);
if (rateInfo.recType === RecTypes.AGN_BIN_DRY) continue;
appIdSet.add(String(f.appId));
}
const appIds = [...appIdSet].map(id => require('mongoose').Types.ObjectId(id));
debug(`fix-xt-apps: found ${appIds.length} LQD NT app IDs — scanning for avgXtError in (0, 1.0)…`);
// avgXtError in (0, 1.0) is physically impossible for LQD (integer metres).
// Process in batches — Application._id is indexed.
const BATCH = 500;
let totalUnset = 0;
for (let i = 0; i < appIds.length; i += BATCH) {
const batch = appIds.slice(i, i + BATCH);
const filter = { _id: { $in: batch }, avgXtError: { $gt: 0, $lt: 1.0 } };
if (cfg.dryRun) {
const count = await Application.countDocuments(filter);
if (count) debug(`[dry-run] fix-xt-apps: batch ${i}-${i + batch.length}: ${count} apps would be reset`);
} else {
const result = await Application.updateMany(filter, { $unset: { avgXtError: 1 } });
totalUnset += result.modifiedCount;
}
if ((i / BATCH) % 20 === 0)
debug(`fix-xt-apps: progress ${i + batch.length}/${appIds.length} apps scanned${cfg.dryRun ? '' : `, ${totalUnset} reset so far`}`);
}
if (cfg.dryRun) {
debug('fix-xt-apps: [dry-run] complete — run without --dry-run to apply');
} else {
debug(`fix-xt-apps: unset avgXtError on ${totalUnset} Application(s) — run migration to recompute`);
}
}
/**
* Process all AppDetail records for one AppFile in a single streaming pass.
*
* @param {ObjectId} fileId
*
* Returns aggregate accumulators AND the first valid coordinate encountered:
* { sprLength, flightLength, speedSum, speedCount, xtSum, xtCount,
* hdopSum, hdopCount, firstLat, firstLon }
*
* firstLat/firstLon capture the first record whose lat and lon are valid numbers,
* regardless of sprayStat. They are used for timezone lookup when computing
* datetime fields, eliminating a separate findOne query.
*
* Validity gates (matching job_worker conventions):
* sprLength — spray-ON segment (prevStat > 0 OR curStat > 0) AND distance ≤ 1000 m AND time gap ≤ 120 s
* flightLength — distance ≤ 1000 m AND time gap ≤ 120 s (all GPS movements)
* avgXtError — spray-on (sprayStat 1 or 3) records within valid segments, xTrack ≠ 0
* avgHdop — spray-on (sprayStat > 0) records within valid segments, stdHdop > 0
*/
async function processFile(fileId) {
let sprLength = 0;
let flightLength = 0;
let speedSum = 0;
let speedCount = 0;
let xtSum = 0;
let xtCount = 0;
let hdopSum = 0;
let hdopCount = 0;
let reqRateSum = 0; // For computing appRate from existing AppDetail lhaReq (SatLoc backfill)
let reqRateCount = 0;
let firstLat = null;
let firstLon = null;
let prevLon = null;
let prevLat = null;
let prevGpsTime = null;
let prevStat = -999;
// Sort by gpsTime for correct consecutive-point distance calculation.
// Tie-break by _id for determinism when gpsTime == 0 (legacy).
const cursor = AppDetail
.find({ fileId })
.select('lat lon gpsTime sprayStat grSpeed xTrack stdHdop lhaReq')
.sort({ gpsTime: 1, _id: 1 })
.lean()
.cursor();
for await (const rec of cursor) {
const curLon = rec.lon;
const curLat = rec.lat;
const curGpsTime = rec.gpsTime;
const curStat = rec.sprayStat || 0;
// ── Capture first valid coordinate for datetime timezone lookup ──────────
if (firstLat === null && typeof curLat === 'number' && typeof curLon === 'number') {
firstLat = curLat;
firstLon = curLon;
}
if (prevStat !== -999 &&
(typeof curLon === 'number') && (typeof curLat === 'number') &&
(typeof prevLon === 'number') && (typeof prevLat === 'number')) {
const d = haversineMeters(prevLon, prevLat, curLon, curLat);
// Time gap with midnight-rollover handling (gpsTime is seconds-of-day)
let timeDif = (typeof curGpsTime === 'number' && typeof prevGpsTime === 'number')
? curGpsTime - prevGpsTime
: Infinity;
if (timeDif < 0 && Math.abs(timeDif) >= 80000) timeDif = 86400 - prevGpsTime + curGpsTime;
// ── Valid segment: both distance AND time gate ───────────────────────────
const segValid = d <= MAX_SEGMENT_METERS && timeDif > 0 && timeDif <= 120;
// ── Spray length: spray-ON segment within valid (both-gate) window ────────
// Matches job_worker _computeSprLength which applies both distance AND time gates.
if (segValid && (prevStat > 0 || curStat > 0)) {
sprLength += d;
}
// ── Flight length: all GPS movements within valid segments ───────────────
if (segValid) {
flightLength += d;
}
// ── XT error: spray-on records (stat 1 = on-swath, 3 = swath entry) ──────
// Only within valid segments; exclude zero (no reading) and non-numeric values.
if (segValid && (curStat === 1 || curStat === 3) &&
typeof rec.xTrack === 'number' && rec.xTrack !== 0) {
xtSum += Math.abs(rec.xTrack);
xtCount += 1;
}
// ── HDOP: spray-on records within valid segments, stdHdop > 0 ────────────
if (segValid && curStat > 0 && typeof rec.stdHdop === 'number' && rec.stdHdop > 0) {
hdopSum += rec.stdHdop;
hdopCount += 1;
}
}
// ── Speed accumulation (all spray-on, no segment gate needed) ────────────
if (curStat > 0 && typeof rec.grSpeed === 'number' && rec.grSpeed > 0) {
speedSum += rec.grSpeed;
speedCount += 1;
}
// ── Prescribed rate: all spray-on records, no segment gate ───────────────
// lhaReq is a configured target value (same for all records from the same
// flow controller setup), not a position measurement. No segment gate needed.
// Used to backfill appRate for SatLoc apps where it was hardcoded to 0 at import.
if (curStat > 0 && typeof rec.lhaReq === 'number' && rec.lhaReq > 0) {
reqRateSum += rec.lhaReq;
reqRateCount += 1;
}
prevLon = curLon;
prevLat = curLat;
prevGpsTime = curGpsTime;
prevStat = curStat;
}
return { sprLength, flightLength, speedSum, speedCount, xtSum, xtCount, hdopSum, hdopCount, reqRateSum, reqRateCount, firstLat, firstLon };
}
/**
* Process all files for one Application with bounded parallelism.
*
* Returns:
* appSprLength — total spray length across all files (m)
* appFlightLength — total flight length across all files (m)
* avgSpraySpeed — weighted avg ground speed across all files (m/s, null if no data)
* avgXtError — weighted avg abs cross-track error (m, null if no data)
* avgHdop — weighted avg HDOP over spray-on records (null if no data)
* fileOps — array of { fileId, sprLength, flightLength } for per-file bulkWrite
* firstLat — first valid latitude found across all files (null if none)
* firstLon — first valid longitude found across all files (null if none)
*/
async function processApplication(_app, appFiles) {
if (!appFiles.length) {
return {
appSprLength: 0, appFlightLength: 0,
avgSpraySpeed: null, avgXtError: null, avgHdop: null,
appRate: null,
fileOps: [],
firstLat: null, firstLon: null,
};
}
// Process files in small parallel batches (bounded concurrency)
const fileResults = [];
for (let i = 0; i < appFiles.length; i += cfg.concurrency) {
const slice = appFiles.slice(i, i + cfg.concurrency);
const results = await Promise.all(slice.map(f => processFile(f._id)));
for (let j = 0; j < slice.length; j++) {
fileResults.push({ file: slice[j], result: results[j] });
}
}
let appSprLength = 0;
let appFlightLength = 0;
let totalSpeedSum = 0;
let totalSpeedCnt = 0;
let totalXtSum = 0;
let totalXtCnt = 0;
let totalHdopSum = 0;
let totalHdopCnt = 0;
let totalReqRateSum = 0;
let totalReqRateCnt = 0;
let firstLat = null;
let firstLon = null;
const fileOps = [];
for (const { file, result } of fileResults) {
appSprLength += result.sprLength;
appFlightLength += result.flightLength;
totalSpeedSum += result.speedSum;
totalSpeedCnt += result.speedCount;
totalXtSum += result.xtSum;
totalXtCnt += result.xtCount;
totalHdopSum += result.hdopSum;
totalHdopCnt += result.hdopCount;
totalReqRateSum += result.reqRateSum;
totalReqRateCnt += result.reqRateCount;
// Keep the first valid coordinate found across all files (files are processed
// in array order, which mirrors the order AppFile returned them).
if (firstLat === null && result.firstLat !== null) {
firstLat = result.firstLat;
firstLon = result.firstLon;
}
fileOps.push({ fileId: file._id, sprLength: result.sprLength, flightLength: result.flightLength });
}
const avgSpraySpeed = totalSpeedCnt > 0 ? totalSpeedSum / totalSpeedCnt : null;
const avgXtError = totalXtCnt > 0 ? totalXtSum / totalXtCnt : null;
const avgHdop = totalHdopCnt > 0 ? totalHdopSum / totalHdopCnt : null;
// null = no spray-on records with lhaReq; 0 treated same as null by caller
const appRate = totalReqRateCnt > 0 ? totalReqRateSum / totalReqRateCnt : null;
return { appSprLength, appFlightLength, avgSpraySpeed, avgXtError, avgHdop, appRate, fileOps, firstLat, firstLon };
}
// ─── Flow accuracy backfill (Application-level only) ─────────────────────────
/**
* Backfill Application.flowAccuracyPct for documents that already have
* totalSprayMat > 0, totalSprayed > 0, and appRate > 0 but no flowAccuracyPct.
* No AppDetail queries needed — all three source fields live on Application.
*/
async function backfillFlowAccuracy() {
debug('─'.repeat(60));
debug('Pass 2: backfill flowAccuracyPct …');
const filter = {
markedDelete: { $ne: true },
flowAccuracyPct: { $exists: false },
totalSprayed: { $gt: 0 },
totalSprayMat: { $gt: 0 },
appRate: { $gt: 0 },
};
if (cfg.tierDays) {
const cutoff = new Date(Date.now() - cfg.tierDays * 86400 * 1000);
filter._id = { $gte: objectIdFromDate(cutoff) };
} else if (cfg.fromDate) {
filter._id = { $gte: objectIdFromDate(cfg.fromDate + 'T00:00:00Z') };
}
debug('flowAccuracy filter: %o', filter);
if (cfg.dryRun) {
const count = await Application.countDocuments(filter);
debug(`[dry-run] Would update ${count} Application docs with flowAccuracyPct`);
debug('─'.repeat(60));
return;
}
// MongoDB 4.2+ aggregation pipeline update — computes the field server-side
const result = await Application.updateMany(filter, [
{
$set: {
flowAccuracyPct: {
$round: [
{ $multiply: [{ $divide: [{ $divide: ['$totalSprayMat', '$totalSprayed'] }, '$appRate'] }, 100] },
2]
}
}
}
]);
debug(`flowAccuracyPct backfill: matched=${result.matchedCount} modified=${result.modifiedCount}`);
debug('─'.repeat(60));
}
// ─── Diagnostic: apps missing legacy start/end datetime ──────────────────────
/**
* Report apps that are missing legacy startDateTime/endDateTime and therefore
* cannot have their UTC fields backfilled by this script.
* Kept from backfill_application_datetimes.js for operator visibility.
*/
async function reportMissingLegacyDateApps(limit = 100) {
const missingLegacyQuery = {
$or: [
{ startDateTime: { $exists: false } },
{ startDateTime: null },
{ endDateTime: { $exists: false } },
{ endDateTime: null }
]
};
const totalMissingLegacy = await App.countDocuments(missingLegacyQuery);
if (!totalMissingLegacy) return;
const apps = await App.find(
missingLegacyQuery,
'_id jobId status proStatus startDateTime endDateTime createdDate updateDate errorMsg'
).sort({ _id: 1 }).limit(limit).lean();
// Job._id is a numeric auto-increment field (mongoose-sequence, inc_field: '_id').
// Application.jobId stores that same numeric _id directly.
const jobIds = [
...new Set(
apps
.map(a => a.jobId)
.filter(v => v !== null && v !== undefined && Number.isFinite(Number(v)))
.map(v => Number(v))
)
];
const jobs = jobIds.length
? await Job.find({ _id: { $in: jobIds } }, '_id name status').lean()
: [];
const jobMapById = new Map(jobs.map(j => [Number(j._id), j]));
console.log('');
console.log(
`[migrate] Apps missing legacy start/end datetime (cannot be backfilled): ` +
`total=${totalMissingLegacy}, showing=${apps.length}`
);
for (const app of apps) {
const files = await AppFile.find({ appId: app._id, markedDelete: { $ne: true } }, { _id: 1 }).lean();
const fileIds = files.map(f => f._id);
// appId has no index and obsoleted — never fall back to it on a billion-doc collection
const detailCount = fileIds.length
? await AppDetail.countDocuments({ fileId: { $in: fileIds } })
: 0;
const job = (app.jobId !== null && app.jobId !== undefined)
? (jobMapById.get(Number(app.jobId)) || null)
: null;
const likelyNoDataFiles = files.length === 0 || detailCount === 0;
console.log(
`[migrate] appId=${app._id}`
+ ` App-jobId=${app.jobId || 'null'}`
+ ` jobId=${job ? job._id : 'n/a'}`
+ ` jobStatus=${job && job.status !== undefined ? job.status : 'n/a'}`
+ ` appStatus=${app.status !== undefined ? app.status : 'n/a'}`
+ ` proStatus=${app.proStatus !== undefined ? app.proStatus : 'n/a'}`
+ ` startDateTime=${app.startDateTime || 'null'}`
+ ` endDateTime=${app.endDateTime || 'null'}`
+ ` appFiles=${files.length}`
+ ` appDetails=${detailCount}`
+ ` likelyNoDataFiles=${likelyNoDataFiles ? 'yes' : 'no'}`
);
}
if (totalMissingLegacy > apps.length) {
console.log(
`[migrate] ... ${totalMissingLegacy - apps.length} more apps omitted. ` +
`Use --missing-limit <n> to show more.`
);
}
}
// ─── Main migration ───────────────────────────────────────────────────────────
async function migrate() {
debug('Config: %o', { ...cfg, envFile });
// ── One-time LQD AgNav xTrack repair ─────────────────────────────────────────
if (cfg.revertRepairAgnXt) {
if (!cfg.revertRepairN) {
debug('revert-repair-agn-xt: --revert-repair-agn-xt=N requires a file count (e.g. --revert-repair-agn-xt=170500)');
return;
}
await revertRepairAgnXtData(cfg.revertRepairN);
if (!cfg.force) {
debug('revert complete. Run migration to recompute avgXtError:');
debug(' node scripts/migrate_applications.js');
return;
}
}
if (cfg.repairAgnXt) {
await repairAgnXtData();
if (!cfg.force) {
debug('repair-agn-xt complete. Restart the server with the new code, then run the migration:');
debug(' node scripts/migrate_applications.js');
return;
}
}
if (cfg.fixDecodedXt) {
await fixDecodedXtData();
if (!cfg.force) {
debug('fix-decoded-xt complete. Run migration to recompute avgXtError:');
debug(' node scripts/migrate_applications.js');
return;
}
}
if (cfg.fixXtApps) {
await fixXtApps();
if (!cfg.force) {
debug('fix-xt-apps complete. Run migration to recompute avgXtError:');
debug(' node scripts/migrate_applications.js');
return;
}
}
// ── Build Application filter ────────────────────────────────────────────────
const appFilter = { markedDelete: { $ne: true } };
// Tier by recency
if (cfg.tierDays) {
const cutoff = new Date(Date.now() - cfg.tierDays * 86400 * 1000);
appFilter._id = { $gte: objectIdFromDate(cutoff) };
debug(`Tier: apps from last ${cfg.tierDays} days (>= ${cutoff.toISOString()})`);
} else if (cfg.fromDate) {
appFilter._id = { $gte: objectIdFromDate(cfg.fromDate + 'T00:00:00Z') };
debug(`Tier: apps from ${cfg.fromDate} onward`);
}
// Unless --force, build a union $or from the conditions of both original scripts,
// controlled by the skip flags so we only match what we intend to process.
if (!cfg.force) {
const orConditions = [];
if (!cfg.skipDatetime) {
// Datetime conditions: fields not yet written.
// NOTE: utcOffset: 0 is intentionally excluded — it is a valid computed value for
// UTC+0 locations (UK, Ireland, Portugal). Including it causes infinite re-processing.
// Use --force to recompute apps that have utcOffset: 0 from a buggy prior run.
orConditions.push(
{ utcOffset: { $exists: false } },
{ startDateTimeUTC: { $exists: false } },
{ endDateTimeUTC: { $exists: false } },
{ $expr: { $gt: ['$startDateTimeUTC', '$endDateTimeUTC'] } } // Inverted dates
);
}
if (!cfg.skipAggregates) {
// Aggregate conditions: fields not yet written (existence check only).
// null and 0 variants are intentionally excluded:
// - null means "processed, no spray/flight data found" (valid result for empty apps)
// - 0 means "processed, computed to be zero" (valid for apps with no movement)
// Including null/0 here causes infinite re-processing of apps with no spray records.
// Use --force to recompute apps whose values are suspected to be incorrect.
orConditions.push(
{ avgSpraySpeed: { $exists: false } },
{ totalSprLength: { $exists: false } },
{ totalFlightLength: { $exists: false } },
{ avgXtError: { $exists: false } },
{ avgHdop: { $exists: false } },
{ totalSprayMatUnit: { $exists: false } },
// SatLoc apps: appRate was hardcoded to 0 at import time.
// Re-select when rate data is present (totalSprayMat > 0) so we can compute
// appRate from AppDetail lhaReq and then allow Pass 2 to set flowAccuracyPct.
{ appRate: 0, totalSprayed: { $gt: 0 }, totalSprayMat: { $gt: 0 } }
);
}
if (orConditions.length) {
appFilter.$or = orConditions;
}
// If both passes are skipped and no orConditions, filter remains as-is (matches none
// in normal usage — operator will see 0 apps processed).
}
debug('App filter: %o', appFilter);
const total = await Application.countDocuments(appFilter);
debug(`Applications to process: ${total}`);
if (!total) {
debug('No applications matched selection criteria.');
if (!cfg.skipDatetime) {
// Check whether any apps lack the legacy dates that this script requires
const diag = {
totalApps: await Application.countDocuments({}),
missingStartDateTime: await Application.countDocuments({ startDateTime: { $exists: false } }),
nullStartDateTime: await Application.countDocuments({ startDateTime: null }),
missingStartDateTimeUTC: await Application.countDocuments({ startDateTimeUTC: { $exists: false } }),
missingEndDateTimeUTC: await Application.countDocuments({ endDateTimeUTC: { $exists: false } }),
missingUtcOffset: await Application.countDocuments({ utcOffset: { $exists: false } }),
zeroUtcOffset: await Application.countDocuments({ utcOffset: 0 }),
};
debug(`Diagnostics: ${JSON.stringify(diag)}`);
if (
diag.missingStartDateTime > 0 || diag.nullStartDateTime > 0
) {
await reportMissingLegacyDateApps(cfg.missingLimit);
}
}
return;
}
// ── Stats ───────────────────────────────────────────────────────────────────
const stats = {
examined: 0,
updated: 0,
skipped: 0, // no AppFile records / no AppDetail data
errors: 0,
startedAt: Date.now(),
};
// ── Pending bulk-write buffers ───────────────────────────────────────────────
let appBulk = []; // Application updateOne ops
let fileBulk = []; // AppFile updateOne ops
async function flushBulk() {
if (cfg.dryRun) {
debug(`[dry-run] Would write ${appBulk.length} Application + ${fileBulk.length} AppFile ops`);
appBulk = [];
fileBulk = [];
return;
}
const writes = [];
if (appBulk.length) writes.push(Application.bulkWrite(appBulk, { ordered: false }));
if (fileBulk.length) writes.push(AppFile.bulkWrite(fileBulk, { ordered: false }));
await Promise.all(writes);
appBulk = [];
fileBulk = [];
}
// ── Stream Applications — newest first ──────────────────────────────────────
// Select startDateTime and endDateTime in addition to aggregate fields so that
// we can compute datetime fields in the same pass.
const appCursor = Application
.find(appFilter)
.select('_id startDateTime endDateTime avgSpraySpeed totalSprLength totalSprayMatUnit')
.sort({ _id: -1 })
.lean()
.cursor();
for await (const app of appCursor) {
stats.examined++;
try {
const $set = {};
if (!cfg.skipAggregates) {
// ── Pass 1A + 1B (aggregates mode): full AppDetail streaming pass ────────
// firstLat/firstLon are captured for free during the streaming pass.
const appFiles = await AppFile
.find({ appId: app._id, markedDelete: { $ne: true } })
.select('_id meta totalSprayMat totalSprayMatUnit')
.lean();
if (!appFiles.length) {
stats.skipped++;
} else {
const {
appSprLength, appFlightLength,
avgSpraySpeed, avgXtError, avgHdop,
appRate,
fileOps,
firstLat, firstLon,
} = await processApplication(app, appFiles);
// Aggregate fields — always write all values, including null.
// Writing null explicitly marks the app as "processed" so the $exists: false
// selection condition stops matching it on subsequent runs.
$set.totalSprLength = appSprLength;
$set.totalFlightLength = appFlightLength;
$set.avgSpraySpeed = avgSpraySpeed; // null when no spray-on records
$set.avgXtError = avgXtError; // null when no XT data
$set.avgHdop = avgHdop; // null when no HDOP data
// Only overwrite appRate when we computed a positive value from lhaReq.
// This avoids stomping a correctly-set AgNav appRate with null.
if (appRate !== null && appRate > 0) $set.appRate = appRate;
// For AgNav apps where lhaReq=0 in all binary records (device firmware didn't
// record it), fall back to the Q-file / job planned rate stored in AppFile.meta.
// Applies regardless of flow controller usage — the planned rate is always the
// prescribed reference for flowAccuracyPct.
if (!$set.appRate) {
for (const f of appFiles) {
const meta = f.meta;
if (meta && meta.appRate > 0) {
const metricRate = metricAppRateFromMeta(meta);
if (metricRate > 0) {
$set.appRate = Math.round(metricRate * 100) / 100;
break;
}
}
}
}
// totalSprayMatUnit: take from the first AppFile that has both positive
// totalSprayMat and a valid unit — mirrors the aggregation rule in job_worker.
if (!app.totalSprayMatUnit) {
for (const f of appFiles) {
if (f.totalSprayMat > 0 && f.totalSprayMatUnit) {
$set.totalSprayMatUnit = f.totalSprayMatUnit;
break;
}
}
}
// Per-file updates
for (const op of fileOps) {
fileBulk.push({
updateOne: {
filter: { _id: op.fileId },
update: { $set: { totalSprLength: op.sprLength, totalFlightLength: op.flightLength } },
},
});
}
// ── Pass 1B: datetime fields (reuse coordinate from streaming pass) ────
if (!cfg.skipDatetime && app.startDateTime && firstLat !== null) {
const dateFields = appDateTime.buildApplicationDateFields({
startDateTime: app.startDateTime,
endDateTime: app.endDateTime,
latitude: firstLat,
longitude: firstLon,
});
$set.utcOffset = dateFields.utcOffset;
$set.startDateTimeUTC = dateFields.startDateTimeUTC;
$set.endDateTimeUTC = dateFields.endDateTimeUTC;
} else if (!cfg.skipDatetime && app.startDateTime && firstLat === null) {
debug(`Skip datetime for app ${app._id}: no coordinate found in AppDetail records`);
}
stats.updated++;
}
} else {
// ── Pass 1B only (--skip-aggregates): lightweight findOne for datetime ──
if (!cfg.skipDatetime && app.startDateTime) {
const referenceDetail = await getReferenceDetail(app._id);
if (!referenceDetail) {
stats.skipped++;
debug(`Skip ${app._id}: no reference detail with coordinates found`);
} else {
const dateFields = appDateTime.buildApplicationDateFields({
startDateTime: app.startDateTime,
endDateTime: app.endDateTime,
latitude: referenceDetail.lat,
longitude: referenceDetail.lon,
});
$set.utcOffset = dateFields.utcOffset;
$set.startDateTimeUTC = dateFields.startDateTimeUTC;
$set.endDateTimeUTC = dateFields.endDateTimeUTC;
stats.updated++;
}
} else if (!cfg.skipDatetime && !app.startDateTime) {
// No startDateTime — nothing to compute; not counted as a skip
// (the query may have matched on aggregate conditions for a different app)
stats.skipped++;
debug(`Skip datetime for app ${app._id}: no startDateTime`);
}
}
// Queue Application update if we have anything to write
if (Object.keys($set).length) {
appBulk.push({
updateOne: {
filter: { _id: app._id },
update: { $set },
},
});
}
} catch (err) {
debug(`Error processing app ${app._id}: ${err.message}`);
stats.errors++;
}
// Flush when batch is full
if (appBulk.length >= cfg.batchSize) {
await flushBulk();
}
// Progress log
if (stats.examined % PROGRESS_LOG_INTERVAL === 0) {
const elapsed = ((Date.now() - stats.startedAt) / 1000).toFixed(1);
const rate = (stats.examined / parseFloat(elapsed)).toFixed(1);
debug(
`Progress: examined=${stats.examined}/${total} updated=${stats.updated} ` +
`skipped=${stats.skipped} errors=${stats.errors} ` +
`elapsed=${elapsed}s rate=${rate} apps/s`
);
}
}
// Final flush
await flushBulk();
const elapsed = ((Date.now() - stats.startedAt) / 1000).toFixed(1);
debug('─'.repeat(60));
debug('Pass 1 complete.');
debug(` Examined : ${stats.examined}`);
debug(` Updated : ${stats.updated}`);
debug(` Skipped : ${stats.skipped} (no files, no detail data, or no startDateTime)`);
debug(` Errors : ${stats.errors}`);
debug(` Duration : ${elapsed}s`);
if (!cfg.dryRun && stats.updated > 0) {
if (!cfg.skipAggregates) {
debug(' Fields set (aggregates): Application.avgSpraySpeed, totalSprLength, totalFlightLength, avgXtError, avgHdop');
debug(' AppFile.totalSprLength, AppFile.totalFlightLength');
}
if (!cfg.skipDatetime) {
debug(' Fields set (datetime): Application.utcOffset, startDateTimeUTC, endDateTimeUTC');
}
}
if (cfg.dryRun) debug(' (DRY RUN — no writes performed)');
debug('─'.repeat(60));
// ── Pass 2: backfill flowAccuracyPct (Application-level only, skipped with --skip-aggregates) ──
if (!cfg.skipAggregates) {
await backfillFlowAccuracy();
}
// ── Post-run diagnostic: report apps that can never have datetime backfilled ──
if (!cfg.skipDatetime) {
await reportMissingLegacyDateApps(cfg.missingLimit);
}
return stats;
}
// ─── Entry point ──────────────────────────────────────────────────────────────
process
.on('uncaughtException', err => { debug('Uncaught:', err); process.exit(1); })
.on('unhandledRejection', err => { debug('Unhandled rejection:', err); process.exit(1); });
async function main() {
const dbConn = new DBConnection('migrate-applications');
try {
await dbConn.initialize({ setupExitHandlers: false });
debug('DB connected');
await migrate();
} catch (err) {
debug('Fatal error:', err);
} finally {
await dbConn.close();
process.exit(0);
}
}
main();