@tmlmobilidade/export-data 20260304.1625.33 → 20260421.1523.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +6 -0
- package/dist/prompts/export-types.js +3 -0
- package/dist/prompts/filter-line-ids.js +7 -13
- package/dist/prompts/filter-stop-ids.js +6 -13
- package/dist/tasks/executive-summary-setup/avg-trips-day.js +58 -0
- package/dist/tasks/executive-summary-setup/empty-runs.js +100 -0
- package/dist/tasks/executive-summary-setup/index.js +248 -0
- package/dist/tasks/executive-summary-setup/km.js +50 -0
- package/dist/tasks/executive-summary-setup/median-speed.js +80 -0
- package/dist/tasks/executive-summary-setup/on-board-sales.js +58 -0
- package/dist/tasks/executive-summary-setup/passenger-impact.js +46 -0
- package/dist/tasks/executive-summary-setup/passengers-transported.js +56 -0
- package/dist/tasks/executive-summary-setup/paxperkm.js +73 -0
- package/dist/tasks/executive-summary-setup/trips.js +132 -0
- package/dist/tasks/executive-summary-setup/tripstatus.js +114 -0
- package/dist/types.js +1 -0
- package/dist/utils/dates-helper.js +3 -0
- package/dist/utils/parse-id-list.js +37 -0
- package/package.json +4 -3
- package/dist/index.d.ts +0 -2
- package/dist/prompts/access-key.d.ts +0 -1
- package/dist/prompts/export-types.d.ts +0 -2
- package/dist/prompts/filter-agency-ids.d.ts +0 -1
- package/dist/prompts/filter-dates.d.ts +0 -5
- package/dist/prompts/filter-line-ids.d.ts +0 -1
- package/dist/prompts/filter-pattern-ids.d.ts +0 -1
- package/dist/prompts/filter-stop-ids.d.ts +0 -1
- package/dist/prompts/filter-types.d.ts +0 -1
- package/dist/prompts/filter-vehicle-ids.d.ts +0 -1
- package/dist/prompts/hashedshape-ids.d.ts +0 -1
- package/dist/prompts/validation-group-fields.d.ts +0 -2
- package/dist/tasks/apex-validations/validations-aggregated.d.ts +0 -18
- package/dist/tasks/apex-validations/validations-raw.d.ts +0 -2
- package/dist/tasks/hashed-shapes/hashed-shapes-geojson.d.ts +0 -4
- package/dist/tasks/rides/rides-raw.d.ts +0 -2
- package/dist/tasks/sams/sams-raw.d.ts +0 -2
- package/dist/tasks/sams/sams-raw.types.d.ts +0 -18
- package/dist/tasks/vehicle-events/vehicle-events-raw.d.ts +0 -2
- package/dist/types.d.ts +0 -35
- package/dist/utils/credential-storage.d.ts +0 -18
- package/dist/utils/hashed-shapes-to-geojson.d.ts +0 -3
- package/dist/utils/init-context.d.ts +0 -2
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { exportTypeLabels, exportTypesWithoutFilters } from './types.js';
|
|
|
20
20
|
import { initExportContext } from './utils/init-context.js';
|
|
21
21
|
import { intro, log, outro, tasks } from '@clack/prompts';
|
|
22
22
|
import { ASCII_CM_SHORT } from '@tmlmobilidade/consts';
|
|
23
|
+
import { exportExecutiveSummary } from './tasks/executive-summary-setup/index.js';
|
|
23
24
|
/* * */
|
|
24
25
|
await (async function main() {
|
|
25
26
|
//
|
|
@@ -106,6 +107,11 @@ await (async function main() {
|
|
|
106
107
|
task: async (message) => await exportSamsRaw({ context, message }),
|
|
107
108
|
title: exportTypeLabels['sams-raw'],
|
|
108
109
|
},
|
|
110
|
+
{
|
|
111
|
+
enabled: exportTypes.includes('executive-summary'),
|
|
112
|
+
task: async (message) => await exportExecutiveSummary({ context, message }),
|
|
113
|
+
title: exportTypeLabels['executive-summary'],
|
|
114
|
+
},
|
|
109
115
|
]);
|
|
110
116
|
//
|
|
111
117
|
// Terminate the process
|
|
@@ -23,6 +23,9 @@ export async function promptExportTypes() {
|
|
|
23
23
|
'5. SAMs': [
|
|
24
24
|
{ label: exportTypeLabels['sams-raw'], value: 'sams-raw' },
|
|
25
25
|
],
|
|
26
|
+
'6. Sumário Executivo': [
|
|
27
|
+
{ label: exportTypeLabels['executive-summary'], value: 'executive-summary' },
|
|
28
|
+
],
|
|
26
29
|
},
|
|
27
30
|
required: true,
|
|
28
31
|
});
|
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
/* * */
|
|
2
|
+
import { parseIdListWithRanges, validateIdListWithRanges } from '../utils/parse-id-list.js';
|
|
2
3
|
import { note, text } from '@clack/prompts';
|
|
3
4
|
/* * */
|
|
5
|
+
const DIGITS = 4;
|
|
4
6
|
export async function promptFilterByLineIds() {
|
|
5
7
|
//
|
|
6
8
|
note('FILTRAR POR LINE ID:\n'
|
|
7
9
|
+ ' • Introduz os Line IDs separados por vírgulas. Exemplo: 1001,1002,etc...\n'
|
|
10
|
+
+ ' • Introduz o intervalo de Line IDs. Exemplo: 1001-1002\n'
|
|
11
|
+
+ ' • Ambos os formatos podem ser introduzidos juntos. Exemplo: 1001-1002,1003,1004\n'
|
|
8
12
|
+ ' • Se não introduzires nenhum Line ID, este filtro não será aplicado.');
|
|
9
13
|
const value = await text({
|
|
10
14
|
message: 'Line IDs:',
|
|
11
|
-
placeholder: '1001,1002
|
|
12
|
-
validate(
|
|
13
|
-
if (value.length === 0)
|
|
14
|
-
return;
|
|
15
|
-
const ids = value.replace(/,$/, '').split(',').map(id => id.trim());
|
|
16
|
-
const invalidIds = ids.filter(id => !/^\d{4}$/.test(id));
|
|
17
|
-
if (invalidIds.length > 0)
|
|
18
|
-
return `Estes IDs são inválidos: ${invalidIds.join(', ')}`;
|
|
19
|
-
return;
|
|
20
|
-
},
|
|
15
|
+
placeholder: '1001-1004 ou 1001,1002,...',
|
|
16
|
+
validate: v => (v.length ? validateIdListWithRanges(v, DIGITS) : undefined),
|
|
21
17
|
});
|
|
22
|
-
|
|
23
|
-
return [];
|
|
24
|
-
return value.split(',').map(id => id.trim());
|
|
18
|
+
return value ? parseIdListWithRanges(String(value), DIGITS) : [];
|
|
25
19
|
}
|
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
/* * */
|
|
2
|
+
import { parseIdListWithRanges, validateIdListWithRanges } from '../utils/parse-id-list.js';
|
|
2
3
|
import { note, text } from '@clack/prompts';
|
|
3
4
|
/* * */
|
|
5
|
+
const DIGITS = 6;
|
|
4
6
|
export async function promptFilterByStopIds() {
|
|
5
7
|
//
|
|
6
8
|
note('FILTRAR POR STOP ID:\n'
|
|
7
9
|
+ ' • Introduz os Stop IDs separados por vírgulas. Exemplo: 010101,020202,etc...\n'
|
|
10
|
+
+ ' • Introduz o intervalo de Stop IDs. Exemplo: 01001-01002\n'
|
|
8
11
|
+ ' • Não te esqueças do zero à esquerda.\n'
|
|
9
12
|
+ ' • Se não introduzires nenhum Stop ID, este filtro não será aplicado.');
|
|
10
13
|
const value = await text({
|
|
11
14
|
message: 'Stop IDs:',
|
|
12
|
-
placeholder: '010101,020202
|
|
13
|
-
validate(
|
|
14
|
-
if (value.length === 0)
|
|
15
|
-
return;
|
|
16
|
-
const ids = value.replace(/,$/, '').split(',').map(id => id.trim());
|
|
17
|
-
const invalidIds = ids.filter(id => !/^\d{6}$/.test(id));
|
|
18
|
-
if (invalidIds.length > 0)
|
|
19
|
-
return `Estes IDs são inválidos: ${invalidIds.join(', ')}`;
|
|
20
|
-
return;
|
|
21
|
-
},
|
|
15
|
+
placeholder: '010001-010004 ou 010101,020202,...',
|
|
16
|
+
validate: v => (v.length ? validateIdListWithRanges(v, DIGITS) : undefined),
|
|
22
17
|
});
|
|
23
|
-
|
|
24
|
-
return [];
|
|
25
|
-
return value.split(',').map(id => id.trim());
|
|
18
|
+
return value ? parseIdListWithRanges(String(value), DIGITS) : [];
|
|
26
19
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Dates } from '@tmlmobilidade/dates';
|
|
2
|
+
import { fetchCalendarData } from '@tmlmobilidade/go-performance-pckg-dates';
|
|
3
|
+
import { rides } from '@tmlmobilidade/interfaces';
|
|
4
|
+
export async function calculateAverageRidesByAgencyByDayType({ context, message }) {
|
|
5
|
+
message('Calculating average rides per agency by day type...');
|
|
6
|
+
const ridesCollection = await rides.getCollection();
|
|
7
|
+
const startDateStr = Dates.fromOperationalDate(context.dates.start, 'Europe/Lisbon').unix_timestamp;
|
|
8
|
+
const endDateStr = Dates.fromOperationalDate(context.dates.end, 'Europe/Lisbon').unix_timestamp;
|
|
9
|
+
// Load calendar JSON
|
|
10
|
+
const calendarJson = await fetchCalendarData();
|
|
11
|
+
// Build calendar map: operational_date -> CalendarEntry
|
|
12
|
+
const calendarMap = new Map();
|
|
13
|
+
for (const day of calendarJson) {
|
|
14
|
+
calendarMap.set(day.date.toString(), day);
|
|
15
|
+
}
|
|
16
|
+
// Mongo aggregation: count total rides per agency and date
|
|
17
|
+
const aggCursor = ridesCollection.aggregate([
|
|
18
|
+
{
|
|
19
|
+
$match: {
|
|
20
|
+
agency_id: { $exists: true },
|
|
21
|
+
start_time_scheduled: { $gte: startDateStr, $lte: endDateStr },
|
|
22
|
+
system_status: 'complete',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
$group: {
|
|
27
|
+
_id: { agencyId: '$agency_id', operationalDate: '$operational_date' },
|
|
28
|
+
totalTrips: { $sum: 1 },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
]);
|
|
32
|
+
// Map in-memory: (agencyId + dayType) -> totalRides / totalDays
|
|
33
|
+
const aggregationMap = new Map();
|
|
34
|
+
for await (const doc of aggCursor) {
|
|
35
|
+
const agencyId = doc._id.agencyId;
|
|
36
|
+
const operationalDate = doc._id.operationalDate;
|
|
37
|
+
const calendarProps = calendarMap.get(operationalDate);
|
|
38
|
+
const dayType = calendarProps?.day_type ?? '1';
|
|
39
|
+
const key = `${agencyId}:${dayType}`;
|
|
40
|
+
if (!aggregationMap.has(key)) {
|
|
41
|
+
aggregationMap.set(key, { agencyId, dayType, totalDays: 0, totalRides: 0 });
|
|
42
|
+
}
|
|
43
|
+
const entry = aggregationMap.get(key);
|
|
44
|
+
entry.totalRides += doc.totalTrips;
|
|
45
|
+
entry.totalDays += 1;
|
|
46
|
+
}
|
|
47
|
+
// Build final results
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const value of aggregationMap.values()) {
|
|
50
|
+
results.push({
|
|
51
|
+
agencyId: value.agencyId,
|
|
52
|
+
averageRides: parseFloat((value.totalRides / value.totalDays).toFixed(2)),
|
|
53
|
+
dayType: value.dayType,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
message(`Processed ${results.length} agency/day-type combinations`);
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Dates } from '@tmlmobilidade/dates';
|
|
2
|
+
import { rides } from '@tmlmobilidade/interfaces';
|
|
3
|
+
/* * */
|
|
4
|
+
/**
|
|
5
|
+
* Calculates observed trips metrics including % of trips with zero passengers
|
|
6
|
+
*/
|
|
7
|
+
export async function calculateObservedTrips({ context, message }) {
|
|
8
|
+
message('Calculating observed trips metrics...');
|
|
9
|
+
// Get the rides collection from MongoDB
|
|
10
|
+
const ridesCollection = await rides.getCollection();
|
|
11
|
+
// Convert context dates to YYYYMMDD
|
|
12
|
+
const startDateStr = Dates.fromOperationalDate(context.dates.start, 'Europe/Lisbon').unix_timestamp;
|
|
13
|
+
const endDateStr = Dates.fromOperationalDate(context.dates.end, 'Europe/Lisbon').unix_timestamp;
|
|
14
|
+
message(`Date range: ${startDateStr} to ${endDateStr}`);
|
|
15
|
+
// Trips with passengers_observed = 0
|
|
16
|
+
const pipelineZero = [
|
|
17
|
+
{
|
|
18
|
+
$match: {
|
|
19
|
+
agency_id: { $exists: true },
|
|
20
|
+
passengers_observed: 0,
|
|
21
|
+
start_time_scheduled: { $gte: startDateStr, $lte: endDateStr },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
$group: {
|
|
26
|
+
_id: { agencyId: '$agency_id', date: '$operational_date' },
|
|
27
|
+
tripszeropassengers: { $sum: 1 },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
$project: {
|
|
32
|
+
_id: 0,
|
|
33
|
+
agencyId: '$_id.agencyId',
|
|
34
|
+
date: '$_id.date',
|
|
35
|
+
tripszeropassengers: 1,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const zeroCursor = ridesCollection.aggregate(pipelineZero);
|
|
40
|
+
const zeroResults = new Map();
|
|
41
|
+
for await (const doc of zeroCursor) {
|
|
42
|
+
const key = `${doc.date}-${doc.agencyId}`;
|
|
43
|
+
zeroResults.set(key, doc);
|
|
44
|
+
}
|
|
45
|
+
// Trips with passengers_observed != 0
|
|
46
|
+
const pipelineNonZero = [
|
|
47
|
+
{
|
|
48
|
+
$match: {
|
|
49
|
+
agency_id: { $exists: true },
|
|
50
|
+
operational_date: { $gte: startDateStr, $lte: endDateStr },
|
|
51
|
+
passengers_observed: { $ne: 0 },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
$group: {
|
|
56
|
+
_id: { agencyId: '$agency_id', date: '$operational_date' },
|
|
57
|
+
tripswpassengers: { $sum: 1 },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
$project: {
|
|
62
|
+
_id: 0,
|
|
63
|
+
agencyId: '$_id.agencyId',
|
|
64
|
+
date: '$_id.date',
|
|
65
|
+
tripswpassengers: 1,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const nonZeroCursor = ridesCollection.aggregate(pipelineNonZero);
|
|
70
|
+
const nonZeroResults = new Map();
|
|
71
|
+
for await (const doc of nonZeroCursor) {
|
|
72
|
+
const key = `${doc.date}-${doc.agencyId}`;
|
|
73
|
+
nonZeroResults.set(key, doc);
|
|
74
|
+
}
|
|
75
|
+
// Merge results and calculate tripszeropassengers%
|
|
76
|
+
const mergedResults = [];
|
|
77
|
+
const allKeys = new Set([...nonZeroResults.keys(), ...zeroResults.keys()]);
|
|
78
|
+
for (const key of allKeys) {
|
|
79
|
+
const zero = zeroResults.get(key);
|
|
80
|
+
const nonZero = nonZeroResults.get(key);
|
|
81
|
+
const dateStr = zero?.date ?? nonZero?.date ?? '';
|
|
82
|
+
const agencyId = zero?.agencyId ?? nonZero?.agencyId ?? '';
|
|
83
|
+
const formattedDate = Dates.fromOperationalDate(dateStr, 'Europe/Lisbon').toFormat('dd/MM/yyyy');
|
|
84
|
+
const tripszeropassengers = zero?.tripszeropassengers ?? 0;
|
|
85
|
+
const tripswpassengers = nonZero?.tripswpassengers ?? 0;
|
|
86
|
+
// Calculate % of trips with zero passengers
|
|
87
|
+
const tripszeropassengersPct = tripswpassengers > 0
|
|
88
|
+
? parseFloat(((tripszeropassengers / tripswpassengers) * 100).toFixed(1))
|
|
89
|
+
: 0;
|
|
90
|
+
mergedResults.push({
|
|
91
|
+
agencyId,
|
|
92
|
+
date: formattedDate,
|
|
93
|
+
tripswpassengers,
|
|
94
|
+
tripszeropassengers,
|
|
95
|
+
tripszeropassengersPct, // include the percentage
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
message(`Processed ${mergedResults.length} observed trips metrics`);
|
|
99
|
+
return mergedResults;
|
|
100
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { log } from 'node:console';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { calculateAverageRidesByAgencyByDayType } from './avg-trips-day.js';
|
|
4
|
+
import { calculateObservedTrips } from './empty-runs.js';
|
|
5
|
+
import { calculateSupplyMetrics } from './km.js';
|
|
6
|
+
import { calculateMedianSpeed } from './median-speed.js';
|
|
7
|
+
import { calculateOnBoardSales } from './on-board-sales.js';
|
|
8
|
+
import { calculateAffectedPassengers } from './passenger-impact.js';
|
|
9
|
+
import { calculateDemandFromMetrics } from './passengers-transported.js';
|
|
10
|
+
import { calculatePassengersPerKm } from './paxperkm.js';
|
|
11
|
+
import { calculateCompletedTrips, calculatePlannedTrips } from './trips.js';
|
|
12
|
+
import { calculateDailyServiceCompliance } from './tripstatus.js';
|
|
13
|
+
// Helper
|
|
14
|
+
function normalizeDate(date) {
|
|
15
|
+
const isoMatch = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
16
|
+
if (isoMatch)
|
|
17
|
+
return `${isoMatch[3]}/${isoMatch[2]}/${isoMatch[1]}`;
|
|
18
|
+
const dmyMatch = date.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
19
|
+
if (dmyMatch)
|
|
20
|
+
return date;
|
|
21
|
+
const compactMatch = date.match(/^(\d{4})(\d{2})(\d{2})$/);
|
|
22
|
+
if (compactMatch)
|
|
23
|
+
return `${compactMatch[3]}/${compactMatch[2]}/${compactMatch[1]}`;
|
|
24
|
+
throw new Error(`Unknown date format: ${date}`);
|
|
25
|
+
}
|
|
26
|
+
// Export
|
|
27
|
+
export async function exportExecutiveSummary({ context, message }) {
|
|
28
|
+
message('Starting export of Executive Summary JSON...');
|
|
29
|
+
// Fetch metrics
|
|
30
|
+
const medianSpeedResults = await calculateMedianSpeed({ context, message });
|
|
31
|
+
const demandMetrics = await calculateDemandFromMetrics({ context, message });
|
|
32
|
+
const plannedTripsMetrics = await calculatePlannedTrips({ context, message });
|
|
33
|
+
const completedTripsMetrics = await calculateCompletedTrips({ context, message });
|
|
34
|
+
const observedTripsMetrics = await calculateObservedTrips({ context, message });
|
|
35
|
+
const circulationMetrics = await calculateSupplyMetrics({ context, message });
|
|
36
|
+
const affectedPassengersMetrics = await calculateAffectedPassengers({ context, message });
|
|
37
|
+
const passengersPerKmMetrics = await calculatePassengersPerKm({ context, message });
|
|
38
|
+
const onBoardSalesMetrics = await calculateOnBoardSales({ context, message });
|
|
39
|
+
log(`✓ Median Speeds: ${medianSpeedResults.length}`);
|
|
40
|
+
log(`✓ Demand: ${demandMetrics.length}`);
|
|
41
|
+
log(`✓ Planned: ${plannedTripsMetrics.length}`);
|
|
42
|
+
log(`✓ Completed: ${completedTripsMetrics.length}`);
|
|
43
|
+
log(`✓ Observed trips: ${observedTripsMetrics.length}`);
|
|
44
|
+
log(`✓ Circulations: ${circulationMetrics.length}`);
|
|
45
|
+
log(`✓ Affected passengers: ${affectedPassengersMetrics.length}`);
|
|
46
|
+
log(`✓ Passengers per Km: ${passengersPerKmMetrics.length}`);
|
|
47
|
+
log(`✓ On-board sales: ${onBoardSalesMetrics.length}`);
|
|
48
|
+
// Merge all metrics by date + agency
|
|
49
|
+
const mergedResults = new Map();
|
|
50
|
+
function ensureRow(date, agencyId) {
|
|
51
|
+
const key = `${date ?? 'NA'}-${agencyId}`;
|
|
52
|
+
if (!mergedResults.has(key)) {
|
|
53
|
+
mergedResults.set(key, {
|
|
54
|
+
date,
|
|
55
|
+
agencyId,
|
|
56
|
+
totalpassengers: 0,
|
|
57
|
+
byCategory: {},
|
|
58
|
+
byProduct: {},
|
|
59
|
+
pax_affected_by_failures: 0,
|
|
60
|
+
pax_affected_by_delay: 0,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return mergedResults.get(key);
|
|
64
|
+
}
|
|
65
|
+
// Demand
|
|
66
|
+
for (const row of demandMetrics) {
|
|
67
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
68
|
+
r.totalpassengers = row.totalpassengers;
|
|
69
|
+
r.byCategory = {
|
|
70
|
+
on_board_sale: row.byCategory?.on_board_sale ?? 0,
|
|
71
|
+
subscription: row.byCategory?.subscription ?? 0,
|
|
72
|
+
prepaid: row.byCategory?.prepaid ?? 0,
|
|
73
|
+
};
|
|
74
|
+
r.byProduct = { ...row.byProduct };
|
|
75
|
+
}
|
|
76
|
+
// Planned
|
|
77
|
+
for (const row of plannedTripsMetrics) {
|
|
78
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
79
|
+
r.plannedTrips = row.plannedTrips;
|
|
80
|
+
}
|
|
81
|
+
// Completed
|
|
82
|
+
for (const row of completedTripsMetrics) {
|
|
83
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
84
|
+
r.completedTrips = row.completedTrips;
|
|
85
|
+
}
|
|
86
|
+
// Observed Trips
|
|
87
|
+
for (const row of observedTripsMetrics) {
|
|
88
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
89
|
+
r.tripsZeroPassengers = row.tripszeropassengers;
|
|
90
|
+
r.tripsWithPassengers = row.tripswpassengers;
|
|
91
|
+
}
|
|
92
|
+
// Circulations (KM)
|
|
93
|
+
for (const row of circulationMetrics) {
|
|
94
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
95
|
+
r.vkmsObserved = (r.vkmsObserved ?? 0) + (row.vkmsobserved ?? 0);
|
|
96
|
+
r.vkmsScheduled = (r.vkmsScheduled ?? 0) + (row.vkmsscheduled ?? 0);
|
|
97
|
+
}
|
|
98
|
+
// Median Speed
|
|
99
|
+
for (const row of medianSpeedResults) {
|
|
100
|
+
const r = ensureRow(undefined, row.agencyId);
|
|
101
|
+
r.medianSpeed = row.medianSpeed ?? 0;
|
|
102
|
+
}
|
|
103
|
+
// Affected passengers
|
|
104
|
+
for (const row of affectedPassengersMetrics) {
|
|
105
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
106
|
+
r.pax_affected_by_failures += row.estimatedpax_affected_by_failures ?? 0;
|
|
107
|
+
}
|
|
108
|
+
// Passengers per Km
|
|
109
|
+
for (const row of passengersPerKmMetrics) {
|
|
110
|
+
const r = ensureRow(undefined, row.agencyId);
|
|
111
|
+
r.passengersPerKm = row.passengersPerKm;
|
|
112
|
+
}
|
|
113
|
+
// On-board Sales
|
|
114
|
+
for (const row of onBoardSalesMetrics) {
|
|
115
|
+
const r = ensureRow(row.date, row.agencyId);
|
|
116
|
+
r.onBoardSalesPr = (r.onBoardSalesPr ?? 0) + row['onboard-sales-pr'];
|
|
117
|
+
}
|
|
118
|
+
// Helper for median calculation
|
|
119
|
+
function calculateMedian(values) {
|
|
120
|
+
if (!values.length)
|
|
121
|
+
return 0;
|
|
122
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
123
|
+
const middle = Math.floor(sorted.length / 2);
|
|
124
|
+
return sorted.length % 2 === 0
|
|
125
|
+
? parseFloat(((sorted[middle - 1] + sorted[middle]) / 2).toFixed(2))
|
|
126
|
+
: parseFloat(sorted[middle].toFixed(2));
|
|
127
|
+
}
|
|
128
|
+
// Aggregate by agency
|
|
129
|
+
const totalsByAgency = {};
|
|
130
|
+
for (const row of mergedResults.values()) {
|
|
131
|
+
const agencyId = row.agencyId;
|
|
132
|
+
if (!totalsByAgency[agencyId]) {
|
|
133
|
+
totalsByAgency[agencyId] = {
|
|
134
|
+
agencyId,
|
|
135
|
+
totalPassengers: { value: 0 },
|
|
136
|
+
trips: {
|
|
137
|
+
planned: 0,
|
|
138
|
+
completed: 0,
|
|
139
|
+
completedPct: 0,
|
|
140
|
+
withPassengers: 0,
|
|
141
|
+
zeroPassengers: { count: 0, percentage: 0 },
|
|
142
|
+
},
|
|
143
|
+
category: { on_board_sale: 0, subscription: 0, prepaid: 0 },
|
|
144
|
+
km: { observed: 0, scheduled: 0, observedPct: 0 },
|
|
145
|
+
product_id: {
|
|
146
|
+
'id-prod-navegante-metro': 0,
|
|
147
|
+
'id-prod-navegante-metro-sub23-grat': 0,
|
|
148
|
+
'id-prod-navegante-65': 0,
|
|
149
|
+
'others': 0,
|
|
150
|
+
},
|
|
151
|
+
dailyServiceCompliance: { earlyRides: 0, fiveMinDelays: 0, onTimeRides: 0, earlyRidesPct: 0, fiveMinDelaysPct: 0, onTimeRidesPct: 0 },
|
|
152
|
+
medianSpeed: 0,
|
|
153
|
+
passengersPerKm: 0,
|
|
154
|
+
onBoardSalesPr: 0,
|
|
155
|
+
pax_affected_by_failures: 0,
|
|
156
|
+
pax_affected_by_delay: 0,
|
|
157
|
+
__medianSpeeds: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const t = totalsByAgency[agencyId];
|
|
161
|
+
t.totalPassengers.value += row.totalpassengers ?? 0;
|
|
162
|
+
t.pax_affected_by_failures += row.pax_affected_by_failures ?? 0;
|
|
163
|
+
t.pax_affected_by_delay += row.pax_affected_by_delay ?? 0;
|
|
164
|
+
t.trips.planned += row.plannedTrips ?? 0;
|
|
165
|
+
t.trips.completed += row.completedTrips ?? 0;
|
|
166
|
+
t.trips.withPassengers += row.tripsWithPassengers ?? 0;
|
|
167
|
+
t.trips.zeroPassengers.count += row.tripsZeroPassengers ?? 0;
|
|
168
|
+
t.km.observed += row.vkmsObserved ?? 0;
|
|
169
|
+
t.km.scheduled += row.vkmsScheduled ?? 0;
|
|
170
|
+
if (row.medianSpeed !== undefined && row.medianSpeed !== null) {
|
|
171
|
+
t.__medianSpeeds.push(row.medianSpeed);
|
|
172
|
+
}
|
|
173
|
+
t.category.on_board_sale += row.byCategory?.on_board_sale ?? 0;
|
|
174
|
+
t.category.subscription += row.byCategory?.subscription ?? 0;
|
|
175
|
+
t.category.prepaid += row.byCategory?.prepaid ?? 0;
|
|
176
|
+
t.onBoardSalesPr += row.onBoardSalesPr ?? 0;
|
|
177
|
+
const byProduct = { ...row.byProduct };
|
|
178
|
+
const metro = byProduct['id-prod-navegante-metro'] ?? 0;
|
|
179
|
+
const sub23 = byProduct['id-prod-navegante-metro-sub23-grat'] ?? 0;
|
|
180
|
+
const p65 = byProduct['id-prod-navegante-65'] ?? 0;
|
|
181
|
+
const totalProducts = Object.values(byProduct).reduce((a, b) => a + b, 0);
|
|
182
|
+
const others = totalProducts - (metro + sub23 + p65);
|
|
183
|
+
t.product_id['id-prod-navegante-metro'] += metro;
|
|
184
|
+
t.product_id['id-prod-navegante-metro-sub23-grat'] += sub23;
|
|
185
|
+
t.product_id['id-prod-navegante-65'] += p65;
|
|
186
|
+
t.product_id.others += others;
|
|
187
|
+
t.passengersPerKm += row.passengersPerKm ?? 0;
|
|
188
|
+
}
|
|
189
|
+
// Calculate percentages
|
|
190
|
+
for (const agency of Object.values(totalsByAgency)) {
|
|
191
|
+
agency.medianSpeed = calculateMedian(agency.__medianSpeeds);
|
|
192
|
+
delete agency.__medianSpeeds;
|
|
193
|
+
if (agency.trips.planned > 0)
|
|
194
|
+
agency.trips.completedPct = parseFloat(((agency.trips.completed / agency.trips.planned) * 100).toFixed(1));
|
|
195
|
+
if (agency.trips.withPassengers > 0)
|
|
196
|
+
agency.trips.zeroPassengers.percentage = parseFloat(((agency.trips.zeroPassengers.count / agency.trips.withPassengers) * 100).toFixed(1));
|
|
197
|
+
if (agency.km.scheduled > 0)
|
|
198
|
+
agency.km.observedPct = parseFloat(((agency.km.observed / agency.km.scheduled) * 100).toFixed(1));
|
|
199
|
+
}
|
|
200
|
+
// Daily service compliance
|
|
201
|
+
const dailyResults = await calculateDailyServiceCompliance(context);
|
|
202
|
+
for (const day of Object.keys(dailyResults)) {
|
|
203
|
+
const dayData = dailyResults[day];
|
|
204
|
+
for (const agencyId of Object.keys(dayData.agencies)) {
|
|
205
|
+
if (totalsByAgency[agencyId]) {
|
|
206
|
+
const dailyMetrics = dayData.agencies[agencyId];
|
|
207
|
+
const agencyTotals = totalsByAgency[agencyId];
|
|
208
|
+
agencyTotals.dailyServiceCompliance.earlyRides += dailyMetrics.early_rides;
|
|
209
|
+
agencyTotals.dailyServiceCompliance.fiveMinDelays += dailyMetrics.five_min_delays;
|
|
210
|
+
agencyTotals.dailyServiceCompliance.onTimeRides += dailyMetrics.ontime_rides;
|
|
211
|
+
// Add passengers affected by delays ≥5 min
|
|
212
|
+
agencyTotals.pax_affected_by_delay = (agencyTotals.pax_affected_by_delay ?? 0) + (dailyMetrics['pax-affected-by-delay'] ?? 0);
|
|
213
|
+
const total = agencyTotals.dailyServiceCompliance.earlyRides + agencyTotals.dailyServiceCompliance.fiveMinDelays + agencyTotals.dailyServiceCompliance.onTimeRides;
|
|
214
|
+
if (total > 0) {
|
|
215
|
+
agencyTotals.dailyServiceCompliance.earlyRidesPct = parseFloat(((agencyTotals.dailyServiceCompliance.earlyRides / total) * 100).toFixed(1));
|
|
216
|
+
agencyTotals.dailyServiceCompliance.fiveMinDelaysPct = parseFloat(((agencyTotals.dailyServiceCompliance.fiveMinDelays / total) * 100).toFixed(1));
|
|
217
|
+
agencyTotals.dailyServiceCompliance.onTimeRidesPct = parseFloat(((agencyTotals.dailyServiceCompliance.onTimeRides / total) * 100).toFixed(1));
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
agencyTotals.dailyServiceCompliance.earlyRidesPct = 0;
|
|
221
|
+
agencyTotals.dailyServiceCompliance.fiveMinDelaysPct = 0;
|
|
222
|
+
agencyTotals.dailyServiceCompliance.onTimeRidesPct = 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Add avg_trips_day_type
|
|
228
|
+
const avgRidesByDayType = await calculateAverageRidesByAgencyByDayType({ context, message });
|
|
229
|
+
for (const row of avgRidesByDayType) {
|
|
230
|
+
const agencyTotals = totalsByAgency[row.agencyId];
|
|
231
|
+
if (!agencyTotals)
|
|
232
|
+
continue;
|
|
233
|
+
const dayTypeKey = `avg_trips_day_type ${row.dayType}`;
|
|
234
|
+
agencyTotals[dayTypeKey] = row.averageRides;
|
|
235
|
+
}
|
|
236
|
+
// Build final output
|
|
237
|
+
const finalOutput = {
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
startDayAnalysis: normalizeDate(context.dates.start),
|
|
240
|
+
endDayAnalysis: normalizeDate(context.dates.end),
|
|
241
|
+
totalsByAgency: Object.values(totalsByAgency),
|
|
242
|
+
};
|
|
243
|
+
if (!fs.existsSync(context.output))
|
|
244
|
+
fs.mkdirSync(context.output, { recursive: true });
|
|
245
|
+
const outputPath = `${context.output}/executive-summary-${context.dates.start}-${context.dates.end}.json`;
|
|
246
|
+
fs.writeFileSync(outputPath, JSON.stringify(finalOutput, null, 2), 'utf-8');
|
|
247
|
+
message('✓ Executive Summary JSON export completed');
|
|
248
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { yyyymmddToDashed } from '../../utils/dates-helper.js';
|
|
2
|
+
import { metrics } from '@tmlmobilidade/interfaces';
|
|
3
|
+
/* * */
|
|
4
|
+
export async function calculateSupplyMetrics({ context, message }) {
|
|
5
|
+
message('Calculating supply metrics...');
|
|
6
|
+
const metricsCollection = await metrics.getCollection();
|
|
7
|
+
// Fetch supply documents
|
|
8
|
+
message('Fetching supply metrics...');
|
|
9
|
+
const docs = await metricsCollection.find({
|
|
10
|
+
metric: 'supply_by_agency_by_day',
|
|
11
|
+
}).toArray();
|
|
12
|
+
message(`Found ${docs.length} supply metrics`);
|
|
13
|
+
const results = new Map();
|
|
14
|
+
const startDate = yyyymmddToDashed(context.dates.start);
|
|
15
|
+
const endDate = yyyymmddToDashed(context.dates.end);
|
|
16
|
+
// Process each document
|
|
17
|
+
for (const doc of docs) {
|
|
18
|
+
const agencyId = doc.properties.agency_id;
|
|
19
|
+
for (const [date, dayData] of Object.entries(doc.data)) {
|
|
20
|
+
// Direct YYYY-MM-DD comparison
|
|
21
|
+
if (date < startDate || date > endDate)
|
|
22
|
+
continue;
|
|
23
|
+
const key = `${date}-${agencyId}`;
|
|
24
|
+
if (!results.has(key)) {
|
|
25
|
+
results.set(key, {
|
|
26
|
+
agencyId,
|
|
27
|
+
date,
|
|
28
|
+
vkmsobserved: 0,
|
|
29
|
+
vkmsobservedpct: 0,
|
|
30
|
+
vkmsscheduled: 0,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const entry = results.get(key);
|
|
34
|
+
entry.vkmsobserved += dayData.vkms_observed;
|
|
35
|
+
entry.vkmsscheduled += dayData.vkms_scheduled;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Calculate percentage at the end (after aggregation)
|
|
39
|
+
const finalResults = Array.from(results.values()).map((entry) => {
|
|
40
|
+
const pct = entry.vkmsscheduled > 0
|
|
41
|
+
? Number(((entry.vkmsobserved / entry.vkmsscheduled) * 100).toFixed(2))
|
|
42
|
+
: 0;
|
|
43
|
+
return {
|
|
44
|
+
...entry,
|
|
45
|
+
vkmsobservedpct: pct,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
message(`Processed ${finalResults.length} supply records`);
|
|
49
|
+
return finalResults;
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Dates } from '@tmlmobilidade/dates';
|
|
2
|
+
import { rides } from '@tmlmobilidade/interfaces';
|
|
3
|
+
/**
|
|
4
|
+
* Helper to calculate median from an array of numbers
|
|
5
|
+
*/
|
|
6
|
+
function median(values) {
|
|
7
|
+
if (values.length === 0)
|
|
8
|
+
return 0;
|
|
9
|
+
values.sort((a, b) => a - b);
|
|
10
|
+
const mid = Math.floor(values.length / 2);
|
|
11
|
+
if (values.length % 2 === 0) {
|
|
12
|
+
return (values[mid - 1] + values[mid]) / 2;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
return values[mid];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Calculates median speed per agency (all dates combined)
|
|
20
|
+
* Considers only rides with positive duration and distance
|
|
21
|
+
*/
|
|
22
|
+
export async function calculateMedianSpeed({ context, message }) {
|
|
23
|
+
message('Calculating median speeds per agency...');
|
|
24
|
+
const ridesCollection = await rides.getCollection();
|
|
25
|
+
const startDateStr = Dates.fromOperationalDate(context.dates.start, 'Europe/Lisbon').unix_timestamp;
|
|
26
|
+
const endDateStr = Dates.fromOperationalDate(context.dates.end, 'Europe/Lisbon').unix_timestamp;
|
|
27
|
+
const pipeline = [
|
|
28
|
+
{
|
|
29
|
+
$match: {
|
|
30
|
+
$expr: { $gt: ['$end_time_observed', '$start_time_observed'] },
|
|
31
|
+
agency_id: { $exists: true },
|
|
32
|
+
end_time_observed: { $exists: true },
|
|
33
|
+
extension_scheduled: { $gt: 0 },
|
|
34
|
+
start_time_observed: { $exists: true },
|
|
35
|
+
start_time_scheduled: { $gte: startDateStr, $lte: endDateStr },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
$project: {
|
|
40
|
+
agencyId: '$agency_id',
|
|
41
|
+
distanceKilometers: { $divide: ['$extension_scheduled', 1000] },
|
|
42
|
+
durationHours: {
|
|
43
|
+
$divide: [
|
|
44
|
+
{ $subtract: ['$end_time_observed', '$start_time_observed'] },
|
|
45
|
+
60 * 60 * 1000,
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
$project: {
|
|
52
|
+
agencyId: 1,
|
|
53
|
+
speedKmPerHour: {
|
|
54
|
+
$cond: [
|
|
55
|
+
{ $gt: ['$durationHours', 0] },
|
|
56
|
+
{ $divide: ['$distanceKilometers', '$durationHours'] },
|
|
57
|
+
0,
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
$group: {
|
|
64
|
+
_id: '$agencyId',
|
|
65
|
+
speeds: { $push: '$speedKmPerHour' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const aggCursor = ridesCollection.aggregate(pipeline);
|
|
70
|
+
const results = [];
|
|
71
|
+
for await (const doc of aggCursor) {
|
|
72
|
+
const medianSpeedValue = median(doc.speeds);
|
|
73
|
+
results.push({
|
|
74
|
+
agencyId: doc._id,
|
|
75
|
+
medianSpeed: parseFloat(medianSpeedValue.toFixed(2)),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
message(`Processed ${results.length} agencies`);
|
|
79
|
+
return results;
|
|
80
|
+
}
|