VEU.WathBody/3-app/2-cli/Server.WatchBody.Import/FitParser.cs

258 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Dynastream.Fit;
using FitDateTime = Dynastream.Fit.DateTime;
namespace Server.WatchBody.Import;
/// <summary>Разобранные факты активности (.fit / TCX / GPX) — единый
/// вход WorkoutImporter (spec 01/02 m06).</summary>
public sealed record ActivityFacts(
string FactsJson,
System.DateTime StartedAt,
int? AvgHr,
int? HrRecoveryS,
string FitFileId,
string Device);
/// <summary>Файл не активность или повреждён (spec 01 §1).</summary>
public class FitParseException(string message) : Exception(message);
/// <summary>
/// Потоковый разбор .fit — официальный FIT SDK (tech-req разд. 5):
/// события Decode.MesgEvent, файл читается один раз. Результат —
/// канонический факт workout-facts/1 (tech-req 2.3).
/// </summary>
public class FitParser
{
private readonly decimal _restingHr; // порог возврата пульса (Р3-А)
public FitParser(decimal restingHrSitting = 90) => _restingHr = restingHrSitting;
public ActivityFacts Parse(string path)
{
var session = new SessionAccumulator();
var bytes = System.IO.File.ReadAllBytes(path);
using (var probe = new MemoryStream(bytes))
{
if (!new Decode().IsFIT(probe))
throw new FitParseException($"файл не .fit: {path}");
probe.Position = 0;
if (!new Decode().CheckIntegrity(probe))
throw new FitParseException($"контрольная сумма .fit не сошлась: {path}");
}
var decode = new Decode();
decode.MesgEvent += (_, e) => session.OnMesg(e.mesg);
using var stream = new MemoryStream(bytes);
if (!decode.Read(stream))
throw new FitParseException($"файл не прочитан: {path}");
return session.Build(_restingHr);
}
/// <summary>Генерация .fit для тест-фикстур (spec 01 §4) —
/// энкодер SDK; порядок FileId → DeviceInfo → Record* → Lap* →
/// Set* → Session.</summary>
public static void WriteTestFile(
string path, System.DateTime startUtc,
(int offsetS, int hr)[] records,
(int index, int offsetS, int durationS)[] laps,
(int reps, decimal? weightKg)[] sets,
string? serial = null)
{
var encode = new Encode(ProtocolVersion.V20);
using var output = new FileStream(path, FileMode.Create);
encode.Open(output);
var fileId = new FileIdMesg();
fileId.SetTimeCreated(new FitDateTime(startUtc));
if (serial != null) fileId.SetSerialNumber(uint.Parse(serial));
encode.Write(fileId);
var device = new DeviceInfoMesg();
device.SetTimestamp(new FitDateTime(startUtc));
device.SetGarminProduct(GarminProduct.Fenix6sAsia);
encode.Write(device);
foreach (var (offsetS, hr) in records)
{
var record = new RecordMesg();
record.SetTimestamp(new FitDateTime(startUtc.AddSeconds(offsetS)));
record.SetHeartRate((byte)hr);
encode.Write(record);
}
foreach (var (index, offsetS, durationS) in laps)
{
var lap = new LapMesg();
lap.SetStartTime(new FitDateTime(startUtc.AddSeconds(offsetS)));
lap.SetTotalTimerTime((float)durationS);
encode.Write(lap);
}
foreach (var (reps, weightKg) in sets)
{
var set = new SetMesg();
set.SetStartTime(new FitDateTime(startUtc));
set.SetRepetitions((ushort)reps);
if (weightKg != null) set.SetWeight((float)weightKg);
encode.Write(set);
}
var session = new SessionMesg();
session.SetStartTime(new FitDateTime(startUtc));
session.SetTotalTimerTime((float)(records.Length > 0 ? records[^1].offsetS + 60 : 3600));
session.SetSport(Sport.Training);
encode.Write(session);
encode.Close();
}
private sealed class SessionAccumulator
{
private FileIdMesg? _fileId;
private SessionMesg? _session;
private readonly List<LapMesg> _laps = [];
private readonly List<(System.DateTime time, int hr)> _records = [];
private readonly List<SetMesg> _sets = [];
private DeviceInfoMesg? _device;
public void OnMesg(Mesg mesg)
{
// SDK доставляет базовый Mesg — типизированные классы
// конструируются из него по Num
switch (mesg.Num)
{
case MesgNum.FileId: _fileId = new FileIdMesg(mesg); break;
case MesgNum.Session: _session = new SessionMesg(mesg); break;
case MesgNum.Lap: _laps.Add(new LapMesg(mesg)); break;
case MesgNum.Record:
{
var record = new RecordMesg(mesg);
if (record.GetHeartRate().HasValue && record.GetHeartRate().Value > 0 &&
record.GetTimestamp() != null)
_records.Add((record.GetTimestamp().GetDateTime().ToUniversalTime(),
record.GetHeartRate().Value));
break;
}
case MesgNum.Set: _sets.Add(new SetMesg(mesg)); break;
case MesgNum.DeviceInfo:
{
var device = new DeviceInfoMesg(mesg);
if (device.GetTimestamp() != null &&
(_device == null ||
device.GetTimestamp().GetDateTime() >= _device.GetTimestamp().GetDateTime()))
_device = device;
break;
}
}
}
public ActivityFacts Build(decimal restingHr)
{
if (_session?.GetStartTime() == null)
throw new FitParseException("в файле нет Session — это не активность");
var start = _session.GetStartTime().GetDateTime().ToUniversalTime();
var durationS = _session.GetTotalTimerTime() != null
? (int)Math.Round(_session.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero)
: 0;
var lapsJson = _laps
.Where(l => l.GetStartTime() != null)
.Select((l, i) => new Dictionary<string, object?>
{
["index"] = i,
["start"] = l.GetStartTime().GetDateTime().ToUniversalTime()
.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
["duration_s"] = l.GetTotalTimerTime() != null
? (int)Math.Round(l.GetTotalTimerTime().Value, MidpointRounding.AwayFromZero)
: 0,
})
.ToList();
var recordsJson = _records
.Select(r => new Dictionary<string, object?>
{
["t_offset_s"] = (int)(r.time - start).TotalSeconds,
["hr"] = r.hr,
})
.ToList();
var setsJson = _sets
.Where(s => s.GetRepetitions().HasValue && s.GetRepetitions().Value > 0)
.Select((s, i) =>
{
var item = new Dictionary<string, object?>
{
["index"] = i,
["reps"] = s.GetRepetitions().Value,
};
if (s.GetWeight().HasValue && s.GetWeight().Value > 0)
item["weight_kg"] = Math.Round((decimal)s.GetWeight().Value, 1,
MidpointRounding.AwayFromZero);
return item;
})
.ToList();
var fitFileId = ComposeFileId();
var device = _device == null
? string.Empty
: _device.GetGarminProduct().HasValue
? _device.GetGarminProduct().Value.ToString()
: _device.GetProduct()?.ToString() ?? string.Empty;
var facts = new Dictionary<string, object?>
{
["schema"] = "workout-facts/1",
["session"] = new Dictionary<string, object?>
{
["type"] = _session.GetSport().ToString(),
["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
["duration_s"] = durationS,
},
["laps"] = lapsJson,
["records"] = recordsJson,
["sets"] = setsJson,
["fit_file_id"] = fitFileId,
["device"] = device,
};
int? avgHr = recordsJson.Count == 0
? null
: (int)Math.Round(recordsJson.Average(r => (int)r["hr"]!),
MidpointRounding.AwayFromZero);
var lapEnds = _laps
.Where(l => l.GetStartTime() != null && l.GetTotalTimerTime() != null)
.Select(l => l.GetStartTime().GetDateTime().ToUniversalTime()
.AddSeconds(l.GetTotalTimerTime().Value))
.ToList();
var recovery = HrRecovery(lapEnds, _records, restingHr);
return new ActivityFacts(
System.Text.Json.JsonSerializer.Serialize(facts),
start, avgHr, recovery, fitFileId, device);
}
/// <summary>Конец последнего Lap → первый Record строго после
/// него с hr ниже порога (Ф-13.4, spec 01 §3); нет записей или
/// не опустился — null.</summary>
private static int? HrRecovery(
List<System.DateTime> lapEnds, List<(System.DateTime time, int hr)> records, decimal restingHr)
{
if (lapEnds.Count == 0) return null;
var lastEnd = lapEnds.Max();
var after = records
.Where(r => r.time > lastEnd)
.OrderBy(r => r.time)
.ToList();
var hit = after.FirstOrDefault(r => r.hr < restingHr);
if (hit == default) return null;
return (int)Math.Round((hit.time - lastEnd).TotalSeconds, MidpointRounding.AwayFromZero);
}
private string ComposeFileId() =>
_fileId == null
? throw new FitParseException("в файле нет FileId — не Garmin-экспорт")
: _fileId.GetTimeCreated() == null
? throw new FitParseException("FileId без TimeCreated")
: $"{(_fileId.GetSerialNumber()?.ToString() ?? "nosn")}-" +
$"{_fileId.GetTimeCreated().GetDateTime().ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}";
}
}