@tmlmobilidade/export-data 20260304.1819.11 → 20260421.1529.44

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.
Files changed (42) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/prompts/export-types.js +3 -0
  3. package/dist/prompts/filter-line-ids.js +7 -13
  4. package/dist/prompts/filter-stop-ids.js +6 -13
  5. package/dist/tasks/executive-summary-setup/avg-trips-day.js +68 -0
  6. package/dist/tasks/executive-summary-setup/empty-runs.js +100 -0
  7. package/dist/tasks/executive-summary-setup/index.js +247 -0
  8. package/dist/tasks/executive-summary-setup/km.js +50 -0
  9. package/dist/tasks/executive-summary-setup/median-speed.js +80 -0
  10. package/dist/tasks/executive-summary-setup/on-board-sales.js +58 -0
  11. package/dist/tasks/executive-summary-setup/passenger-impact.js +46 -0
  12. package/dist/tasks/executive-summary-setup/passengers-transported.js +56 -0
  13. package/dist/tasks/executive-summary-setup/paxperkm.js +73 -0
  14. package/dist/tasks/executive-summary-setup/trips.js +132 -0
  15. package/dist/tasks/executive-summary-setup/tripstatus.js +114 -0
  16. package/dist/types.js +1 -0
  17. package/dist/utils/dates-helper.js +3 -0
  18. package/dist/utils/parse-id-list.js +37 -0
  19. package/package.json +4 -3
  20. package/dist/index.d.ts +0 -2
  21. package/dist/prompts/access-key.d.ts +0 -1
  22. package/dist/prompts/export-types.d.ts +0 -2
  23. package/dist/prompts/filter-agency-ids.d.ts +0 -1
  24. package/dist/prompts/filter-dates.d.ts +0 -5
  25. package/dist/prompts/filter-line-ids.d.ts +0 -1
  26. package/dist/prompts/filter-pattern-ids.d.ts +0 -1
  27. package/dist/prompts/filter-stop-ids.d.ts +0 -1
  28. package/dist/prompts/filter-types.d.ts +0 -1
  29. package/dist/prompts/filter-vehicle-ids.d.ts +0 -1
  30. package/dist/prompts/hashedshape-ids.d.ts +0 -1
  31. package/dist/prompts/validation-group-fields.d.ts +0 -2
  32. package/dist/tasks/apex-validations/validations-aggregated.d.ts +0 -18
  33. package/dist/tasks/apex-validations/validations-raw.d.ts +0 -2
  34. package/dist/tasks/hashed-shapes/hashed-shapes-geojson.d.ts +0 -4
  35. package/dist/tasks/rides/rides-raw.d.ts +0 -2
  36. package/dist/tasks/sams/sams-raw.d.ts +0 -2
  37. package/dist/tasks/sams/sams-raw.types.d.ts +0 -18
  38. package/dist/tasks/vehicle-events/vehicle-events-raw.d.ts +0 -2
  39. package/dist/types.d.ts +0 -35
  40. package/dist/utils/credential-storage.d.ts +0 -18
  41. package/dist/utils/hashed-shapes-to-geojson.d.ts +0 -3
  42. 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,etc...',
12
- validate(value) {
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
- if (!value)
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,etc...',
13
- validate(value) {
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
- if (!value)
24
- return [];
25
- return value.split(',').map(id => id.trim());
18
+ return value ? parseIdListWithRanges(String(value), DIGITS) : [];
26
19
  }
@@ -0,0 +1,68 @@
1
+ // ! THIS IS NOT WORKING, BECAUSE THE @tmlmobilidade/go-performance-pckg-dates is not a published package yet.
2
+ // import { type TaskProps } from '../../types.js';
3
+ // import { Dates } from '@tmlmobilidade/dates';
4
+ // import { type CalendarEntry, fetchCalendarData } from '@tmlmobilidade/go-performance-pckg-dates';
5
+ // import { rides } from '@tmlmobilidade/interfaces';
6
+ export {};
7
+ // export interface AgencyAverageRidesByDayTypeResult {
8
+ // agencyId: string
9
+ // averageRides: number
10
+ // dayType: string
11
+ // }
12
+ // export async function calculateAverageRidesByAgencyByDayType(
13
+ // { context, message }: TaskProps,
14
+ // ): Promise<AgencyAverageRidesByDayTypeResult[]> {
15
+ // message('Calculating average rides per agency by day type...');
16
+ // const ridesCollection = await rides.getCollection();
17
+ // const startDateStr = Dates.fromOperationalDate(context.dates.start, 'Europe/Lisbon').unix_timestamp;
18
+ // const endDateStr = Dates.fromOperationalDate(context.dates.end, 'Europe/Lisbon').unix_timestamp;
19
+ // // Load calendar JSON
20
+ // const calendarJson = await fetchCalendarData();
21
+ // // Build calendar map: operational_date -> CalendarEntry
22
+ // const calendarMap = new Map<string, CalendarEntry>();
23
+ // for (const day of calendarJson) {
24
+ // calendarMap.set(day.date.toString(), day);
25
+ // }
26
+ // // Mongo aggregation: count total rides per agency and date
27
+ // const aggCursor = ridesCollection.aggregate([
28
+ // {
29
+ // $match: {
30
+ // agency_id: { $exists: true },
31
+ // start_time_scheduled: { $gte: startDateStr, $lte: endDateStr },
32
+ // system_status: 'complete',
33
+ // },
34
+ // },
35
+ // {
36
+ // $group: {
37
+ // _id: { agencyId: '$agency_id', operationalDate: '$operational_date' },
38
+ // totalTrips: { $sum: 1 },
39
+ // },
40
+ // },
41
+ // ]);
42
+ // // Map in-memory: (agencyId + dayType) -> totalRides / totalDays
43
+ // const aggregationMap = new Map<string, { agencyId: string, dayType: string, totalDays: number, totalRides: number }>();
44
+ // for await (const doc of aggCursor) {
45
+ // const agencyId = doc._id.agencyId;
46
+ // const operationalDate = doc._id.operationalDate;
47
+ // const calendarProps = calendarMap.get(operationalDate);
48
+ // const dayType = calendarProps?.day_type ?? '1';
49
+ // const key = `${agencyId}:${dayType}`;
50
+ // if (!aggregationMap.has(key)) {
51
+ // aggregationMap.set(key, { agencyId, dayType, totalDays: 0, totalRides: 0 });
52
+ // }
53
+ // const entry = aggregationMap.get(key);
54
+ // entry.totalRides += doc.totalTrips;
55
+ // entry.totalDays += 1;
56
+ // }
57
+ // // Build final results
58
+ // const results: AgencyAverageRidesByDayTypeResult[] = [];
59
+ // for (const value of aggregationMap.values()) {
60
+ // results.push({
61
+ // agencyId: value.agencyId,
62
+ // averageRides: parseFloat((value.totalRides / value.totalDays).toFixed(2)),
63
+ // dayType: value.dayType,
64
+ // });
65
+ // }
66
+ // message(`Processed ${results.length} agency/day-type combinations`);
67
+ // return results;
68
+ // }
@@ -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,247 @@
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) continue;
232
+ // const dayTypeKey = `avg_trips_day_type ${row.dayType}` as const;
233
+ // agencyTotals[dayTypeKey] = row.averageRides;
234
+ // }
235
+ // Build final output
236
+ const finalOutput = {
237
+ timestamp: Date.now(),
238
+ startDayAnalysis: normalizeDate(context.dates.start),
239
+ endDayAnalysis: normalizeDate(context.dates.end),
240
+ totalsByAgency: Object.values(totalsByAgency),
241
+ };
242
+ if (!fs.existsSync(context.output))
243
+ fs.mkdirSync(context.output, { recursive: true });
244
+ const outputPath = `${context.output}/executive-summary-${context.dates.start}-${context.dates.end}.json`;
245
+ fs.writeFileSync(outputPath, JSON.stringify(finalOutput, null, 2), 'utf-8');
246
+ message('✓ Executive Summary JSON export completed');
247
+ }
@@ -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
+ }