aves_mio0.31/lib/utils/fit_activity_parser.dart
2026-07-18 13:39:22 +02:00

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,
});
}