612 lines
17 KiB
Dart
612 lines
17 KiB
Dart
// lib/utils/fit_activity_parser.dart
|
|
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:aves/model/activity/activity_track.dart';
|
|
import 'package:fit_sdk/fit_sdk.dart' as fit;
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import 'gps_smoothing.dart';
|
|
|
|
class FitActivityParser {
|
|
static final Distance _distance = Distance();
|
|
|
|
static ActivityTrack parse(
|
|
Uint8List bytes, {
|
|
required String fileName,
|
|
}) {
|
|
final records = <_FitPoint>[];
|
|
|
|
double? sessionDistanceMeters;
|
|
double? sessionElevationGainMeters;
|
|
Duration? sessionDuration;
|
|
DateTime? sessionStartTime;
|
|
String? sport;
|
|
|
|
bool isZepp = false;
|
|
|
|
final decoder = fit.Decode();
|
|
|
|
decoder.onMesg = (dynamic message) {
|
|
final messageName = '${message.name}'.toLowerCase();
|
|
final fields = <String, dynamic>{};
|
|
|
|
for (final dynamic field in message.fields) {
|
|
fields['${field.name}'.toLowerCase()] = field.value;
|
|
}
|
|
|
|
if (_looksLikeZepp(fields)) {
|
|
isZepp = true;
|
|
}
|
|
|
|
if (messageName == 'record') {
|
|
final latRaw = _readNumber(fields, const [
|
|
'position_lat',
|
|
'positionLat',
|
|
'positionlat',
|
|
'lat',
|
|
'latitude',
|
|
]);
|
|
|
|
final lonRaw = _readNumber(fields, const [
|
|
'position_long',
|
|
'positionLong',
|
|
'positionlong',
|
|
'lon',
|
|
'longitude',
|
|
]);
|
|
|
|
if (latRaw == null || lonRaw == null) return;
|
|
|
|
final lat = _toDegrees(latRaw);
|
|
final lon = _toDegrees(lonRaw);
|
|
|
|
if (!lat.isFinite || !lon.isFinite) return;
|
|
if (lat.abs() > 90 || lon.abs() > 180) return;
|
|
|
|
final elevation = _readNumber(fields, const [
|
|
'enhanced_altitude',
|
|
'enhancedAltitude',
|
|
'enhancedaltitude',
|
|
'altitude',
|
|
]);
|
|
|
|
final time = _readDateTime(fields, const [
|
|
'timestamp',
|
|
'time',
|
|
]);
|
|
|
|
records.add(
|
|
_FitPoint(
|
|
latLng: LatLng(lat, lon),
|
|
elevationMeters: elevation,
|
|
time: time,
|
|
rawFields: fields,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (messageName == 'session') {
|
|
sessionDistanceMeters ??= _readNumber(fields, const [
|
|
'total_distance',
|
|
'totalDistance',
|
|
'totaldistance',
|
|
'distance',
|
|
]);
|
|
|
|
// Priorita assoluta per il D+ aggregato presente nel FIT.
|
|
// Questo corrisponde al campo Total Ascent / total_ascent / totalAscent.
|
|
sessionElevationGainMeters ??= _readNumber(fields, const [
|
|
'total_ascent',
|
|
'totalAscent',
|
|
'totalascent',
|
|
'ascent',
|
|
'total_elevation_gain',
|
|
'totalElevationGain',
|
|
'totalElevationGainMeters',
|
|
]);
|
|
|
|
final elapsedSeconds = _readNumber(fields, const [
|
|
'total_elapsed_time',
|
|
'totalElapsedTime',
|
|
'total_timer_time',
|
|
'totalTimerTime',
|
|
]);
|
|
|
|
if (elapsedSeconds != null && elapsedSeconds > 0) {
|
|
sessionDuration = Duration(seconds: elapsedSeconds.round());
|
|
}
|
|
|
|
sessionStartTime ??= _readDateTime(fields, const [
|
|
'start_time',
|
|
'startTime',
|
|
'timestamp',
|
|
'time',
|
|
]);
|
|
|
|
final sessionSport = fields['sport'];
|
|
if (sessionSport != null) {
|
|
sport = _normalizeSport('$sessionSport');
|
|
}
|
|
}
|
|
|
|
if (messageName == 'sport') {
|
|
final sportValue = fields['sport'];
|
|
if (sportValue != null) {
|
|
sport = _normalizeSport('$sportValue');
|
|
}
|
|
}
|
|
};
|
|
|
|
decoder.read(bytes);
|
|
|
|
final points = records
|
|
.where((p) =>
|
|
p.latLng.latitude.isFinite &&
|
|
p.latLng.longitude.isFinite &&
|
|
p.latLng.latitude.abs() <= 90 &&
|
|
p.latLng.longitude.abs() <= 180)
|
|
.toList();
|
|
|
|
points.sort(_compareByTimeKeepingNullsLast);
|
|
|
|
final originalTrack = points.map((p) => p.latLng).toList();
|
|
|
|
// Semplifico solo la visualizzazione. Distanza e quota usano i punti originali.
|
|
final displayTrack = isZepp
|
|
? GpsSmoothing.douglasPeucker(originalTrack, 5.0)
|
|
: originalTrack;
|
|
|
|
final computedDistanceMeters = _computeDistanceFromPoints(points, sport: sport);
|
|
final computedElevationGainMeters = _computeElevationLikeCommercialApps(points);
|
|
final computedDuration = _computeDuration(points);
|
|
final computedStartTime = _computeStartTime(points);
|
|
|
|
// Regola distanza richiesta:
|
|
// 1) se nel FIT esiste totalDistance/total_distance plausibile, uso quello;
|
|
// 2) altrimenti calcolo la distanza dai punti GPS con filtri anti-spike.
|
|
final finalDistanceMeters = _chooseDistanceMeters(
|
|
sessionDistanceMeters: sessionDistanceMeters,
|
|
computedDistanceMeters: computedDistanceMeters,
|
|
);
|
|
|
|
// Regola D+ richiesta:
|
|
// 1) se nel FIT esiste totalAscent/total_ascent plausibile, uso quello;
|
|
// 2) altrimenti calcolo il D+ con smoothing + soglie, stile app commerciali.
|
|
final finalElevationGainMeters = _chooseElevationGainMeters(
|
|
sessionElevationGainMeters: sessionElevationGainMeters,
|
|
computedElevationGainMeters: computedElevationGainMeters,
|
|
);
|
|
|
|
return ActivityTrack(
|
|
name: fileName,
|
|
format: 'FIT',
|
|
tracks: {displayTrack},
|
|
pointCount: points.length,
|
|
distanceMeters: finalDistanceMeters,
|
|
elevationGainMeters: finalElevationGainMeters,
|
|
duration: sessionDuration ?? computedDuration,
|
|
startTime: sessionStartTime ?? computedStartTime,
|
|
sport: sport,
|
|
diagnostics: [
|
|
'isZepp=$isZepp',
|
|
'sessionDistanceMeters=$sessionDistanceMeters',
|
|
'computedDistanceMeters=$computedDistanceMeters',
|
|
'finalDistanceMeters=$finalDistanceMeters',
|
|
'sessionElevationGainMeters=$sessionElevationGainMeters',
|
|
'computedElevationGainMeters=$computedElevationGainMeters',
|
|
'finalElevationGainMeters=$finalElevationGainMeters',
|
|
'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}',
|
|
'elevationMin=${_minElevation(points)}',
|
|
'elevationMax=${_maxElevation(points)}',
|
|
'originalPoints=${points.length}',
|
|
'displayPoints=${displayTrack.length}',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
static bool _looksLikeZepp(Map<String, dynamic> fields) {
|
|
final manufacturer = fields['manufacturer']?.toString().toLowerCase() ?? '';
|
|
final product = fields['product']?.toString().toLowerCase() ?? '';
|
|
final deviceName = fields['device_name']?.toString().toLowerCase() ?? '';
|
|
final descriptor = fields['descriptor']?.toString().toLowerCase() ?? '';
|
|
final serial = fields['serial_number']?.toString().toLowerCase() ?? '';
|
|
final all = '$manufacturer $product $deviceName $descriptor $serial';
|
|
|
|
return all.contains('zepp') ||
|
|
all.contains('amazfit') ||
|
|
all.contains('huami');
|
|
}
|
|
|
|
static double? _readNumber(Map<String, dynamic> fields, List<String> names) {
|
|
for (final name in names) {
|
|
final direct = fields[name];
|
|
if (direct != null) {
|
|
final parsed = _parseNumber(direct);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
|
|
final normalizedName = _normalizeFieldName(name);
|
|
|
|
for (final entry in fields.entries) {
|
|
if (_normalizeFieldName(entry.key) == normalizedName) {
|
|
final parsed = _parseNumber(entry.value);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static String _normalizeFieldName(String value) {
|
|
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
|
}
|
|
|
|
static double? _parseNumber(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is num) return value.toDouble();
|
|
|
|
return double.tryParse(value.toString().replaceAll(',', '.'));
|
|
}
|
|
|
|
static DateTime? _readDateTime(
|
|
Map<String, dynamic> fields,
|
|
List<String> names,
|
|
) {
|
|
for (final name in names) {
|
|
final direct = fields[name];
|
|
if (direct != null) {
|
|
final parsed = _parseDateTime(direct);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
|
|
final normalizedName = _normalizeFieldName(name);
|
|
for (final entry in fields.entries) {
|
|
if (_normalizeFieldName(entry.key) == normalizedName) {
|
|
final parsed = _parseDateTime(entry.value);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static DateTime? _parseDateTime(dynamic value) {
|
|
if (value == null) return null;
|
|
|
|
if (value is DateTime) return value.toUtc();
|
|
|
|
if (value is int && value > 0) {
|
|
// FIT timestamp: secondi dal 1989-12-31 00:00:00 UTC.
|
|
return DateTime.fromMillisecondsSinceEpoch(
|
|
(value + 631065600) * 1000,
|
|
isUtc: true,
|
|
);
|
|
}
|
|
|
|
final parsed = DateTime.tryParse(value.toString());
|
|
return parsed?.toUtc();
|
|
}
|
|
|
|
static double _toDegrees(num raw) {
|
|
final v = raw.toDouble();
|
|
if (v.abs() <= 180) return v;
|
|
return v * 180.0 / 2147483648.0;
|
|
}
|
|
|
|
static double? _computeDistanceFromPoints(
|
|
List<_FitPoint> points, {
|
|
String? sport,
|
|
}) {
|
|
if (points.length < 2) return null;
|
|
|
|
final maxSpeedMps = _maxPlausibleSpeedMps(sport);
|
|
double dist = 0;
|
|
|
|
for (int i = 1; i < points.length; i++) {
|
|
final prev = points[i - 1];
|
|
final curr = points[i];
|
|
|
|
final seg = _distance.as(
|
|
LengthUnit.Meter,
|
|
prev.latLng,
|
|
curr.latLng,
|
|
);
|
|
|
|
if (!seg.isFinite || seg <= 0) continue;
|
|
|
|
final dt = prev.time != null && curr.time != null
|
|
? curr.time!.difference(prev.time!).inSeconds.abs()
|
|
: null;
|
|
|
|
if (dt == null || dt == 0) {
|
|
if (seg > 300) continue;
|
|
dist += seg;
|
|
continue;
|
|
}
|
|
|
|
if (seg < 0.3) continue;
|
|
|
|
final speedMps = seg / dt;
|
|
if (speedMps > maxSpeedMps) continue;
|
|
|
|
dist += seg;
|
|
}
|
|
|
|
return dist > 0 ? dist : null;
|
|
}
|
|
|
|
static double _maxPlausibleSpeedMps(String? sport) {
|
|
final value = sport?.toLowerCase() ?? '';
|
|
|
|
if (value.contains('bici') ||
|
|
value.contains('bike') ||
|
|
value.contains('cycling') ||
|
|
value.contains('mountain')) {
|
|
return 30;
|
|
}
|
|
|
|
if (value.contains('corsa') || value.contains('running')) {
|
|
return 12;
|
|
}
|
|
|
|
if (value.contains('cammin') ||
|
|
value.contains('walking') ||
|
|
value.contains('trekking') ||
|
|
value.contains('hiking')) {
|
|
return 8;
|
|
}
|
|
|
|
return 25;
|
|
}
|
|
|
|
static double? _chooseDistanceMeters({
|
|
required double? sessionDistanceMeters,
|
|
required double? computedDistanceMeters,
|
|
}) {
|
|
final session = _normalizeDistanceMeters(sessionDistanceMeters);
|
|
if (session != null) return session;
|
|
|
|
return _normalizeDistanceMeters(computedDistanceMeters);
|
|
}
|
|
|
|
static double? _normalizeDistanceMeters(double? value) {
|
|
if (value == null || !value.isFinite || value <= 0) return null;
|
|
|
|
// Limite prudente: evita dati palesemente corrotti.
|
|
// 500 km copre ultra, bici lunghe e la maggior parte delle attivita outdoor.
|
|
const maxPlausibleMeters = 500000.0;
|
|
|
|
// Alcuni decoder/librerie possono restituire valori FIT non scalati.
|
|
// total_distance FIT ha scala 100: 1234567 puo significare 12345.67 m.
|
|
if (value > maxPlausibleMeters && value / 100 <= maxPlausibleMeters) {
|
|
return value / 100;
|
|
}
|
|
|
|
if (value > maxPlausibleMeters) return null;
|
|
|
|
return value;
|
|
}
|
|
|
|
static double? _chooseElevationGainMeters({
|
|
required double? sessionElevationGainMeters,
|
|
required double? computedElevationGainMeters,
|
|
}) {
|
|
final session = _normalizeElevationGain(sessionElevationGainMeters);
|
|
if (session != null) return session;
|
|
|
|
return _normalizeElevationGain(computedElevationGainMeters);
|
|
}
|
|
|
|
static double? _normalizeElevationGain(double? value) {
|
|
if (value == null || !value.isFinite || value <= 0) return null;
|
|
|
|
// Valore molto prudente: evita solo dati evidentemente corrotti/scalati male.
|
|
if (value > 20000) return null;
|
|
|
|
return value;
|
|
}
|
|
|
|
/// Fallback quando totalAscent non esiste.
|
|
///
|
|
/// Logica ispirata alle app commerciali:
|
|
/// - rimozione valori altimetrici impossibili;
|
|
/// - smoothing mediano leggero;
|
|
/// - rimozione spike verticali;
|
|
/// - somma delle salite valle-picco solo oltre una soglia minima.
|
|
///
|
|
/// Non calcola mai quota finale - quota iniziale, quindi sui giri circolari
|
|
/// il D+ non si annulla.
|
|
static double? _computeElevationLikeCommercialApps(List<_FitPoint> points) {
|
|
final elevations = points
|
|
.map((p) => p.elevationMeters)
|
|
.whereType<double>()
|
|
.where((e) => e.isFinite && e > -500 && e < 9000)
|
|
.toList();
|
|
|
|
if (elevations.length < 2) return null;
|
|
|
|
final smoothed = _medianSmooth(elevations, radius: 2);
|
|
|
|
const minClimbMeters = 2.0;
|
|
const reverseThresholdMeters = 2.0;
|
|
const maxVerticalJumpMeters = 80.0;
|
|
|
|
double gain = 0;
|
|
double valley = smoothed.first;
|
|
double peak = smoothed.first;
|
|
double previous = smoothed.first;
|
|
|
|
for (int i = 1; i < smoothed.length; i++) {
|
|
final current = smoothed[i];
|
|
final delta = current - previous;
|
|
|
|
if (delta.abs() > maxVerticalJumpMeters) {
|
|
previous = current;
|
|
valley = current;
|
|
peak = current;
|
|
continue;
|
|
}
|
|
|
|
if (current > peak) {
|
|
peak = current;
|
|
}
|
|
|
|
if (current < valley) {
|
|
valley = current;
|
|
peak = current;
|
|
}
|
|
|
|
// Quando scendo abbastanza dal picco, considero terminata una salita.
|
|
if (peak - current >= reverseThresholdMeters) {
|
|
final climb = peak - valley;
|
|
if (climb >= minClimbMeters) {
|
|
gain += climb;
|
|
}
|
|
|
|
valley = current;
|
|
peak = current;
|
|
}
|
|
|
|
previous = current;
|
|
}
|
|
|
|
final finalClimb = peak - valley;
|
|
if (finalClimb >= minClimbMeters) {
|
|
gain += finalClimb;
|
|
}
|
|
|
|
return gain > 0 ? gain : null;
|
|
}
|
|
|
|
static List<double> _medianSmooth(List<double> values, {required int radius}) {
|
|
if (values.length < 3 || radius <= 0) return List<double>.from(values);
|
|
|
|
final result = <double>[];
|
|
|
|
for (int i = 0; i < values.length; i++) {
|
|
final start = i - radius < 0 ? 0 : i - radius;
|
|
final end = i + radius >= values.length ? values.length - 1 : i + radius;
|
|
|
|
final window = <double>[];
|
|
for (int j = start; j <= end; j++) {
|
|
window.add(values[j]);
|
|
}
|
|
window.sort();
|
|
result.add(window[window.length ~/ 2]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static Duration? _computeDuration(List<_FitPoint> points) {
|
|
final times = points.map((p) => p.time).nonNulls.toList();
|
|
if (times.length < 2) return null;
|
|
|
|
times.sort();
|
|
final duration = times.last.difference(times.first);
|
|
return duration.isNegative ? null : duration;
|
|
}
|
|
|
|
static DateTime? _computeStartTime(List<_FitPoint> points) {
|
|
final times = points.map((p) => p.time).nonNulls.toList();
|
|
if (times.isEmpty) return null;
|
|
|
|
times.sort();
|
|
return times.first;
|
|
}
|
|
|
|
static double? _minElevation(List<_FitPoint> points) {
|
|
final values = points
|
|
.map((p) => p.elevationMeters)
|
|
.whereType<double>()
|
|
.where((e) => e.isFinite)
|
|
.toList();
|
|
|
|
if (values.isEmpty) return null;
|
|
values.sort();
|
|
return values.first;
|
|
}
|
|
|
|
static double? _maxElevation(List<_FitPoint> points) {
|
|
final values = points
|
|
.map((p) => p.elevationMeters)
|
|
.whereType<double>()
|
|
.where((e) => e.isFinite)
|
|
.toList();
|
|
|
|
if (values.isEmpty) return null;
|
|
values.sort();
|
|
return values.last;
|
|
}
|
|
|
|
static int _compareByTimeKeepingNullsLast(_FitPoint a, _FitPoint b) {
|
|
final at = a.time;
|
|
final bt = b.time;
|
|
|
|
if (at == null && bt == null) return 0;
|
|
if (at == null) return 1;
|
|
if (bt == null) return -1;
|
|
|
|
return at.compareTo(bt);
|
|
}
|
|
|
|
static double? _normalizeSuspiciousDistance(double? value) {
|
|
return _normalizeDistanceMeters(value);
|
|
}
|
|
|
|
static String _normalizeSport(String raw) {
|
|
final value = raw.trim().toLowerCase();
|
|
final numeric = int.tryParse(value);
|
|
|
|
if (numeric != null) {
|
|
switch (numeric) {
|
|
case 1:
|
|
return 'Corsa';
|
|
case 2:
|
|
return 'Bici';
|
|
case 5:
|
|
return 'Nuoto';
|
|
case 6:
|
|
return 'Camminata';
|
|
case 9:
|
|
return 'Trekking';
|
|
case 20:
|
|
return 'Mountain Bike';
|
|
default:
|
|
return 'Sport $numeric';
|
|
}
|
|
}
|
|
|
|
switch (value) {
|
|
case 'running':
|
|
return 'Corsa';
|
|
case 'cycling':
|
|
return 'Bici';
|
|
case 'walking':
|
|
return 'Camminata';
|
|
case 'hiking':
|
|
return 'Trekking';
|
|
case 'swimming':
|
|
return 'Nuoto';
|
|
default:
|
|
return raw.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
class _FitPoint {
|
|
final LatLng latLng;
|
|
final double? elevationMeters;
|
|
final DateTime? time;
|
|
final Map<String, dynamic> rawFields;
|
|
|
|
const _FitPoint({
|
|
required this.latLng,
|
|
this.elevationMeters,
|
|
required this.time,
|
|
required this.rawFields,
|
|
});
|
|
}
|