573 lines
16 KiB
Dart
573 lines
16 KiB
Dart
// lib/utils/tcx_activity_parser.dart
|
|
import 'package:aves/model/activity/activity_track.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:xml/xml.dart';
|
|
|
|
import 'gps_smoothing.dart';
|
|
|
|
class TcxActivityParser {
|
|
static final Distance _distance = Distance();
|
|
|
|
static ActivityTrack parse(String xml, {required String fileName}) {
|
|
final doc = XmlDocument.parse(xml);
|
|
|
|
final points = <_TcxPoint>[];
|
|
final displayTracks = <List<LatLng>>{};
|
|
|
|
final activityName = _readActivityName(doc, fileName);
|
|
final sport = _readSport(doc);
|
|
|
|
double? fileDistanceMeters;
|
|
double? fileElevationGainMeters;
|
|
|
|
// Nei TCX la struttura tipica e':
|
|
// Activity -> Lap -> Track -> Trackpoint.
|
|
// Mantengo un segmento display per ogni Track, cosi non unisco pezzi separati.
|
|
for (final track in doc.findAllElements('Track')) {
|
|
final segmentPoints = <_TcxPoint>[];
|
|
|
|
for (final tp in track.findElements('Trackpoint')) {
|
|
final lat = _readDouble(tp, const [
|
|
'LatitudeDegrees',
|
|
]);
|
|
|
|
final lon = _readDouble(tp, const [
|
|
'LongitudeDegrees',
|
|
]);
|
|
|
|
final ele = _readDouble(tp, const [
|
|
'AltitudeMeters',
|
|
]);
|
|
|
|
final time = _readDateTime(tp, const [
|
|
'Time',
|
|
]);
|
|
|
|
final distance = _readDouble(tp, const [
|
|
'DistanceMeters',
|
|
]);
|
|
|
|
if (lat == null || lon == null) continue;
|
|
if (!lat.isFinite || !lon.isFinite) continue;
|
|
if (lat.abs() > 90 || lon.abs() > 180) continue;
|
|
|
|
final point = _TcxPoint(
|
|
latLng: LatLng(lat, lon),
|
|
elevationMeters: ele,
|
|
time: time,
|
|
distanceMeters: distance,
|
|
);
|
|
|
|
points.add(point);
|
|
segmentPoints.add(point);
|
|
}
|
|
|
|
if (segmentPoints.isNotEmpty) {
|
|
final latLngs = segmentPoints.map((p) => p.latLng).toList();
|
|
final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0);
|
|
displayTracks.add(smoothed);
|
|
}
|
|
}
|
|
|
|
// Fallback per TCX non standard: se non esistono <Track>, leggo tutti i
|
|
// Trackpoint globali in un unico segmento.
|
|
if (points.isEmpty) {
|
|
final segmentPoints = <_TcxPoint>[];
|
|
|
|
for (final tp in doc.findAllElements('Trackpoint')) {
|
|
final lat = _readDouble(tp, const ['LatitudeDegrees']);
|
|
final lon = _readDouble(tp, const ['LongitudeDegrees']);
|
|
final ele = _readDouble(tp, const ['AltitudeMeters']);
|
|
final time = _readDateTime(tp, const ['Time']);
|
|
final distance = _readDouble(tp, const ['DistanceMeters']);
|
|
|
|
if (lat == null || lon == null) continue;
|
|
if (!lat.isFinite || !lon.isFinite) continue;
|
|
if (lat.abs() > 90 || lon.abs() > 180) continue;
|
|
|
|
final point = _TcxPoint(
|
|
latLng: LatLng(lat, lon),
|
|
elevationMeters: ele,
|
|
time: time,
|
|
distanceMeters: distance,
|
|
);
|
|
|
|
points.add(point);
|
|
segmentPoints.add(point);
|
|
}
|
|
|
|
if (segmentPoints.isNotEmpty) {
|
|
final latLngs = segmentPoints.map((p) => p.latLng).toList();
|
|
final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0);
|
|
displayTracks.add(smoothed);
|
|
}
|
|
}
|
|
|
|
points.sort(_compareByTimeKeepingNullsLast);
|
|
|
|
fileDistanceMeters = _readFileDistanceMeters(doc) ?? _distanceFromTrackpointField(points);
|
|
fileElevationGainMeters = _readFileElevationGainMeters(doc);
|
|
|
|
final computedDistanceMeters = _computeDistanceFromPoints(points);
|
|
final computedElevationGainMeters = _computeElevationLikeCommercialApps(points);
|
|
final computedDuration = _computeDuration(points);
|
|
final computedStartTime = _computeStartTime(points);
|
|
|
|
final finalDistanceMeters = _chooseDistanceMeters(
|
|
fileDistanceMeters: fileDistanceMeters,
|
|
computedDistanceMeters: computedDistanceMeters,
|
|
);
|
|
|
|
final finalElevationGainMeters = _chooseElevationGainMeters(
|
|
fileElevationGainMeters: fileElevationGainMeters,
|
|
computedElevationGainMeters: computedElevationGainMeters,
|
|
);
|
|
|
|
return ActivityTrack(
|
|
name: fileName,
|
|
format: 'TCX',
|
|
tracks: displayTracks,
|
|
routeName: activityName,
|
|
pointCount: points.length,
|
|
distanceMeters: finalDistanceMeters,
|
|
elevationGainMeters: finalElevationGainMeters,
|
|
fileDistanceMeters: fileDistanceMeters,
|
|
fileElevationGainMeters: fileElevationGainMeters,
|
|
computedDistanceMeters: computedDistanceMeters,
|
|
computedElevationGainMeters: computedElevationGainMeters,
|
|
duration: computedDuration,
|
|
startTime: computedStartTime,
|
|
sport: sport,
|
|
diagnostics: [
|
|
'fileDistanceMeters=$fileDistanceMeters',
|
|
'computedDistanceMeters=$computedDistanceMeters',
|
|
'finalDistanceMeters=$finalDistanceMeters',
|
|
'fileElevationGainMeters=$fileElevationGainMeters',
|
|
'computedElevationGainMeters=$computedElevationGainMeters',
|
|
'finalElevationGainMeters=$finalElevationGainMeters',
|
|
'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}',
|
|
'elevationMin=${_minElevation(points)}',
|
|
'elevationMax=${_maxElevation(points)}',
|
|
'originalPoints=${points.length}',
|
|
'displayPoints=${displayTracks.expand((segment) => segment).length}',
|
|
'routeName=$activityName',
|
|
'sport=$sport',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
static String? _readActivityName(XmlDocument doc, String fileName) {
|
|
// TCX spesso usa Activity/Id come data/identificativo.
|
|
final id = doc
|
|
.findAllElements('Activity')
|
|
.expand((activity) => activity.findElements('Id'))
|
|
.map((e) => e.text.trim())
|
|
.firstWhereOrNull((value) => value.isNotEmpty);
|
|
|
|
if (id != null) return id;
|
|
|
|
// Alcuni export inseriscono un nome in Name o Notes.
|
|
final name = doc
|
|
.findAllElements('Name')
|
|
.map((e) => e.text.trim())
|
|
.firstWhereOrNull((value) => value.isNotEmpty);
|
|
|
|
if (name != null) return name;
|
|
|
|
final cleaned = fileName.trim();
|
|
if (cleaned.isEmpty) return null;
|
|
|
|
final dotIndex = cleaned.lastIndexOf('.');
|
|
if (dotIndex > 0) return cleaned.substring(0, dotIndex);
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
static String? _readSport(XmlDocument doc) {
|
|
final sport = doc
|
|
.findAllElements('Activity')
|
|
.map((e) => e.getAttribute('Sport')?.trim())
|
|
.whereType<String>()
|
|
.firstWhereOrNull((value) => value.isNotEmpty);
|
|
|
|
return _normalizeSport(sport);
|
|
}
|
|
|
|
static double? _readFileDistanceMeters(XmlDocument doc) {
|
|
// Nei TCX il dato aggregato normalmente si trova in Lap/DistanceMeters.
|
|
final lapDistances = doc
|
|
.findAllElements('Lap')
|
|
.map((lap) => _readDirectDouble(lap, const ['DistanceMeters']))
|
|
.whereType<double>()
|
|
.where((v) => v.isFinite && v > 0)
|
|
.toList();
|
|
|
|
if (lapDistances.isNotEmpty) {
|
|
final total = lapDistances.fold<double>(0, (sum, value) => sum + value);
|
|
return _normalizeDistanceMeters(total);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static double? _readFileElevationGainMeters(XmlDocument doc) {
|
|
// TCX standard Garmin non contiene sempre TotalAscent, ma alcuni export
|
|
// custom lo aggiungono. Se c'e' ed e' plausibile, lo uso come valore file.
|
|
final values = <double>[];
|
|
|
|
for (final name in const [
|
|
'TotalAscent',
|
|
'TotalAscentMeters',
|
|
'TotalElevationGain',
|
|
'Ascent',
|
|
]) {
|
|
values.addAll(
|
|
doc
|
|
.findAllElements(name)
|
|
.map((e) => double.tryParse(e.text.trim().replaceAll(',', '.')))
|
|
.whereType<double>()
|
|
.where((v) => v.isFinite && v > 0),
|
|
);
|
|
}
|
|
|
|
if (values.isEmpty) return null;
|
|
|
|
// Se sono presenti piu' lap/valori, sommo. Se e' un solo valore, torna quello.
|
|
final total = values.fold<double>(0, (sum, value) => sum + value);
|
|
return _normalizeElevationGain(total);
|
|
}
|
|
|
|
static double? _distanceFromTrackpointField(List<_TcxPoint> points) {
|
|
final values = points
|
|
.map((p) => p.distanceMeters)
|
|
.whereType<double>()
|
|
.where((v) => v.isFinite && v > 0)
|
|
.toList();
|
|
|
|
if (values.length < 2) return null;
|
|
|
|
values.sort();
|
|
final distance = values.last - values.first;
|
|
return _normalizeDistanceMeters(distance > 0 ? distance : values.last);
|
|
}
|
|
|
|
static double? _chooseDistanceMeters({
|
|
required double? fileDistanceMeters,
|
|
required double? computedDistanceMeters,
|
|
}) {
|
|
final file = _normalizeDistanceMeters(fileDistanceMeters);
|
|
if (file != null) return file;
|
|
|
|
return _normalizeDistanceMeters(computedDistanceMeters);
|
|
}
|
|
|
|
static double? _chooseElevationGainMeters({
|
|
required double? fileElevationGainMeters,
|
|
required double? computedElevationGainMeters,
|
|
}) {
|
|
final file = _normalizeElevationGain(fileElevationGainMeters);
|
|
if (file != null) return file;
|
|
|
|
return _normalizeElevationGain(computedElevationGainMeters);
|
|
}
|
|
|
|
static double? _normalizeDistanceMeters(double? value) {
|
|
if (value == null || !value.isFinite || value <= 0) return null;
|
|
|
|
const maxPlausibleMeters = 500000.0;
|
|
|
|
if (value > maxPlausibleMeters && value / 100 <= maxPlausibleMeters) {
|
|
return value / 100;
|
|
}
|
|
|
|
if (value > maxPlausibleMeters) return null;
|
|
|
|
return value;
|
|
}
|
|
|
|
static double? _normalizeElevationGain(double? value) {
|
|
if (value == null || !value.isFinite || value <= 0) return null;
|
|
if (value > 20000) return null;
|
|
return value;
|
|
}
|
|
|
|
static double? _readDouble(XmlElement parent, List<String> names) {
|
|
for (final name in names) {
|
|
final normalizedName = _normalizeXmlName(name);
|
|
|
|
// Uso findAllElements perche' nei TCX lat/lon sono normalmente dentro:
|
|
// Trackpoint -> Position -> LatitudeDegrees / LongitudeDegrees.
|
|
for (final element in parent.findAllElements('*')) {
|
|
if (_normalizeXmlName(element.name.local) == normalizedName) {
|
|
final value = double.tryParse(element.text.trim().replaceAll(',', '.'));
|
|
if (value != null) return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static double? _readDirectDouble(XmlElement parent, List<String> names) {
|
|
for (final name in names) {
|
|
final normalizedName = _normalizeXmlName(name);
|
|
|
|
for (final child in parent.childElements) {
|
|
if (_normalizeXmlName(child.name.local) == normalizedName) {
|
|
final value = double.tryParse(child.text.trim().replaceAll(',', '.'));
|
|
if (value != null) return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static DateTime? _readDateTime(XmlElement parent, List<String> names) {
|
|
for (final name in names) {
|
|
final normalizedName = _normalizeXmlName(name);
|
|
|
|
for (final element in parent.findAllElements('*')) {
|
|
if (_normalizeXmlName(element.name.local) == normalizedName) {
|
|
final value = DateTime.tryParse(element.text.trim());
|
|
if (value != null) return value.toUtc();
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static String _normalizeXmlName(String value) {
|
|
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
|
}
|
|
|
|
static double? _computeDistanceFromPoints(List<_TcxPoint> points) {
|
|
if (points.length < 2) return null;
|
|
|
|
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 > 500) continue;
|
|
dist += seg;
|
|
continue;
|
|
}
|
|
|
|
if (seg < 0.3) continue;
|
|
|
|
final speedMps = seg / dt;
|
|
if (speedMps > 30) continue;
|
|
|
|
dist += seg;
|
|
}
|
|
|
|
return dist > 0 ? dist : null;
|
|
}
|
|
|
|
/// Fallback stile app commerciali:
|
|
/// - rimozione quote impossibili;
|
|
/// - smoothing mediano;
|
|
/// - rimozione spike verticali;
|
|
/// - somma delle salite valle-picco sopra soglia.
|
|
///
|
|
/// Non fa quota finale - quota iniziale, quindi i giri circolari non si annullano.
|
|
static double? _computeElevationLikeCommercialApps(List<_TcxPoint> 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;
|
|
}
|
|
|
|
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<_TcxPoint> 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<_TcxPoint> points) {
|
|
final times = points.map((p) => p.time).nonNulls.toList();
|
|
if (times.isEmpty) return null;
|
|
|
|
times.sort();
|
|
return times.first;
|
|
}
|
|
|
|
static double? _minElevation(List<_TcxPoint> 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<_TcxPoint> 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(_TcxPoint a, _TcxPoint 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 String? _normalizeSport(String? raw) {
|
|
final value = raw?.trim().toLowerCase();
|
|
if (value == null || value.isEmpty) return null;
|
|
|
|
switch (value) {
|
|
case 'running':
|
|
return 'Corsa';
|
|
case 'biking':
|
|
case 'cycling':
|
|
return 'Bici';
|
|
case 'walking':
|
|
return 'Camminata';
|
|
case 'hiking':
|
|
return 'Trekking';
|
|
case 'swimming':
|
|
return 'Nuoto';
|
|
default:
|
|
return raw?.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
class _TcxPoint {
|
|
final LatLng latLng;
|
|
final double? elevationMeters;
|
|
final DateTime? time;
|
|
final double? distanceMeters;
|
|
|
|
const _TcxPoint({
|
|
required this.latLng,
|
|
this.elevationMeters,
|
|
this.time,
|
|
this.distanceMeters,
|
|
});
|
|
}
|
|
|
|
extension _FirstOrNull<E> on Iterable<E> {
|
|
E? get firstOrNull {
|
|
final it = iterator;
|
|
if (!it.moveNext()) return null;
|
|
return it.current;
|
|
}
|
|
|
|
E? firstWhereOrNull(bool Function(E value) test) {
|
|
for (final value in this) {
|
|
if (test(value)) return value;
|
|
}
|
|
return null;
|
|
}
|
|
}
|