OSCAR-code/oscar/SleepLib/loader_plugins/viatom_loader.cpp

395 lines
13 KiB
C++
Raw Normal View History

/* SleepLib Viatom Loader Implementation
*
* Copyright (c) 2019-2020 The OSCAR Team
* (Initial importer written by dave madden <dhm@mersenne.com>)
*
* This file is subject to the terms and conditions of the GNU General Public
* License. See the file COPYING in the main directory of the source code
* for more details. */
//********************************************************************************************
// IMPORTANT!!!
//********************************************************************************************
// Please INCREMENT the viatom_data_version in viatom_loader.h when making changes to this loader
// that change loader behaviour or modify channels.
//********************************************************************************************
#include <QDir>
#include <QTextStream>
#include <QApplication>
#include <QMessageBox>
#include "viatom_loader.h"
#include "SleepLib/machine.h"
static QSet<QString> s_unexpectedMessages;
bool
ViatomLoader::Detect(const QString & path)
{
// This is only used for CPAP machines, when detecting CPAP cards.
qDebug() << "ViatomLoader::Detect(" << path << ")";
return false;
}
int
ViatomLoader::Open(const QString & dirpath)
{
qDebug() << "ViatomLoader::Open(" << dirpath << ")";
Machine* mach = nullptr;
int imported = 0;
int found = 0;
s_unexpectedMessages.clear();
if (QFileInfo(dirpath).isDir()) {
QDir dir(dirpath);
dir.setFilter(QDir::NoDotAndDotDot | QDir::Files | QDir::Hidden);
dir.setNameFilters(getNameFilter());
dir.setSorting(QDir::Name);
for (auto & fi : dir.entryInfoList()) {
mach = OpenFile(fi.canonicalFilePath());
if (mach) imported++;
found++;
}
}
else {
// This filename has already been filtered by QFileDialog.
mach = OpenFile(dirpath);
if (mach) imported++;
found++;
}
if (!found) {
return -1;
}
if (mach) {
qDebug() << "Imported" << imported << "sessions";
mach->Save();
mach->SaveSummaryCache();
p_profile->StoreMachines();
}
if (mach && s_unexpectedMessages.count() > 0 && p_profile->session->warnOnUnexpectedData()) {
// Compare this to the list of messages previously seen for this machine
// and only alert if there are new ones.
QSet<QString> newMessages = s_unexpectedMessages - mach->previouslySeenUnexpectedData();
if (newMessages.count() > 0) {
// TODO: Rework the importer call structure so that this can become an
// emit statement to the appropriate import job.
QMessageBox::information(QApplication::activeWindow(),
QObject::tr("Untested Data"),
QObject::tr("Your Viatom device generated data that OSCAR has never seen before.") +"\n\n"+
QObject::tr("The imported data may not be entirely accurate, so the developers would like a copy of your Viatom files to make sure OSCAR is handling the data correctly.")
,QMessageBox::Ok);
mach->previouslySeenUnexpectedData() += newMessages;
}
}
return imported;
}
Machine* ViatomLoader::OpenFile(const QString & filename)
{
Machine* mach = nullptr;
Session* sess = ParseFile(filename);
if (sess) {
SaveSessionToDatabase(sess);
mach = sess->machine();
}
return mach;
}
Session* ViatomLoader::ParseFile(const QString & filename)
{
QFile file(filename);
if (!file.open(QFile::ReadOnly)) {
qDebug() << "Couldn't open Viatom data file" << filename;
return nullptr;
}
ViatomFile v(file);
if (v.ParseHeader() == false) {
return nullptr;
}
MachineInfo info = newInfo();
// Check whether the enclosing folder looks like a Viatom serial number, and if so, use it.
QString foldername = QFileInfo(filename).dir().dirName();
if (foldername.length() >= 9) {
bool numeric;
foldername.right(4).toInt(&numeric);
if (numeric) {
info.serial = foldername;
}
}
Machine *mach = p_profile->CreateMachine(info);
if (mach->SessionExists(v.sessionid())) {
// Skip already imported session
//qDebug() << filename << "session already exists, skipping" << v.sessionid();
return nullptr;
}
qint64 time_ms = v.timestamp();
m_session = new Session(mach, v.sessionid());
m_session->set_first(time_ms);
QList<ViatomFile::Record> records = v.ReadData();
m_step = v.duration() / records.size() * 1000L;
// Import data
for (auto & rec : records) {
if (rec.oximetry_invalid) {
EndEventList(OXI_Pulse, time_ms);
EndEventList(OXI_SPO2, time_ms);
} else {
AddEvent(OXI_Pulse, time_ms, rec.hr);
AddEvent(OXI_SPO2, time_ms, rec.spo2);
}
AddEvent(POS_Movement, time_ms, rec.motion);
time_ms += m_step;
}
EndEventList(OXI_Pulse, time_ms);
EndEventList(OXI_SPO2, time_ms);
EndEventList(POS_Movement, time_ms);
m_session->set_last(time_ms);
return m_session;
}
void ViatomLoader::SaveSessionToDatabase(Session* sess)
{
Machine* mach = sess->machine();
sess->SetChanged(true);
mach->AddSession(sess);
}
void ViatomLoader::AddEvent(ChannelID channel, qint64 t, EventDataType value)
{
EventList* C = m_importChannels[channel];
if (C == nullptr) {
C = m_session->AddEventList(channel, EVL_Waveform, 1.0, 0.0, 0.0, 0.0, m_step);
Q_ASSERT(C); // Once upon a time AddEventList could return nullptr, but not any more.
m_importChannels[channel] = C;
}
// Add the event
C->AddEvent(t, value);
m_importLastValue[channel] = value;
}
void ViatomLoader::EndEventList(ChannelID channel, qint64 /*t*/)
{
EventList* C = m_importChannels[channel];
if (C != nullptr) {
// The below would be needed for square charts if the first sample represents
// the 4 seconds following the starting timestamp:
//C->AddEvent(t, m_importLastValue[channel]);
// Mark this channel's event list as ended.
m_importChannels[channel] = nullptr;
}
}
QStringList ViatomLoader::getNameFilter()
{
return QStringList("20[0-5][0-9][01][0-9][0-3][0-9][012][0-9][0-5][0-9][0-5][0-9]");
}
static bool viatom_initialized = false;
void
ViatomLoader::Register()
{
if (!viatom_initialized) {
qDebug("Registering ViatomLoader");
RegisterLoader(new ViatomLoader());
//InitModelMap();
viatom_initialized = true;
}
}
// ===============================================================================================
/*
static QString ts(qint64 msecs)
{
// TODO: make this UTC so that tests don't vary by where they're run
return QDateTime::fromMSecsSinceEpoch(msecs).toString(Qt::ISODate);
}
static QString dur(qint64 msecs)
{
qint64 s = msecs / 1000L;
int h = s / 3600; s -= h * 3600;
int m = s / 60; s -= m * 60;
return QString("%1:%2:%3")
.arg(h, 2, 10, QChar('0'))
.arg(m, 2, 10, QChar('0'))
.arg(s, 2, 10, QChar('0'));
}
*/
// TODO: Merge this with PRS1 macros and generalize for all loaders.
#define UNEXPECTED_VALUE(SRC, VALS) { \
QString message = QString("%1:%2: %3 = %4 != %5").arg(__func__).arg(__LINE__).arg(#SRC).arg(SRC).arg(VALS); \
qWarning() << this->m_sessionid << message; \
s_unexpectedMessages += message; \
}
#define CHECK_VALUE(SRC, VAL) if ((SRC) != (VAL)) UNEXPECTED_VALUE(SRC, VAL)
#define CHECK_VALUES(SRC, VAL1, VAL2) if ((SRC) != (VAL1) && (SRC) != (VAL2)) UNEXPECTED_VALUE(SRC, #VAL1 " or " #VAL2)
// for more than 2 values, just write the test manually and use UNEXPECTED_VALUE if it fails
ViatomFile::ViatomFile(QFile & file) : m_file(file)
{
}
bool ViatomFile::ParseHeader()
{
static const int HEADER_SIZE = 40;
QByteArray data = m_file.read(HEADER_SIZE);
if (data.size() < HEADER_SIZE) {
qDebug() << m_file.fileName() << "too short for a Viatom data file";
return false;
}
const unsigned char* header = (const unsigned char*) data.constData();
int sig = header[0] | (header[1] << 8);
int year = header[2] | (header[3] << 8);
int month = header[4];
int day = header[5];
int hour = header[6];
int min = header[7];
int sec = header[8];
if (sig != 0x0003) {
qDebug() << m_file.fileName() << "invalid signature for Viatom data file" << sig;
return false;
}
if ((year < 2015 || year > 2059) || (month < 1 || month > 12) || (day < 1 || day > 31) ||
(hour > 23) || (min > 59) || (sec > 59)) {
qDebug() << m_file.fileName() << "invalid timestamp in Viatom data file";
return false;
}
// It's unclear what the starting timestamp represents: is it the time at which
// the device starts measuring data, and the first sample is 4s after that? Or
// is the starting timestamp the time at which the first 4s average is reported
// (and the first 4 seconds being average precede the starting timestamp)?
//
// If the former, then the chart draws the first sample too early (right at the
// starting timestamp). Technically these should probably be square charts, but
// the code currently forces them to be non-square.
QDateTime data_timestamp = QDateTime(QDate(year, month, day), QTime(hour, min, sec));
m_timestamp = data_timestamp.toMSecsSinceEpoch();
m_sessionid = m_timestamp / 1000L;
int filesize = header[9] | (header[10] << 8); // possibly 32-bit
CHECK_VALUE(header[11], 0);
CHECK_VALUE(header[12], 0);
m_duration = header[13] | (header[14] << 8); // possibly 32-bit
CHECK_VALUE(header[15], 0);
CHECK_VALUE(header[16], 0);
//int spo2_avg = header[17];
//int spo2_min = header[18];
//int spo2_3pct = header[19]; // number of events
//int spo2_4pct = header[20]; // number of events
CHECK_VALUE(header[21], 0);
//int time_under_90pct = header[22]; // in seconds
CHECK_VALUE(header[23], 0);
//int events_under_90pct = header[24]; // number of distinct events
//float o2_score = header[25] * 0.1;
CHECK_VALUE(header[26], 0);
CHECK_VALUE(header[27], 0);
CHECK_VALUE(header[28], 0);
CHECK_VALUE(header[29], 0);
CHECK_VALUE(header[30], 0);
CHECK_VALUE(header[31], 0);
CHECK_VALUE(header[32], 0);
CHECK_VALUE(header[33], 0);
CHECK_VALUE(header[34], 0);
CHECK_VALUE(header[35], 0);
CHECK_VALUE(header[36], 0);
CHECK_VALUE(header[37], 0);
CHECK_VALUE(header[38], 0);
CHECK_VALUE(header[39], 0);
// Calculate timing resolution (in ms) of the data
qint64 datasize = m_file.size() - HEADER_SIZE;
m_record_count = datasize / RECORD_SIZE;
m_resolution = m_duration / m_record_count * 1000L;
if (m_resolution == 2000) {
// Interestingly the file size in the header corresponds the number of
// distinct samples. These files actually double-report each sample!
// So this resolution isn't really the real one. The importer should
// calculate resolution from duration / record count after reading the
// records, which will be deduplicated.
CHECK_VALUE(filesize, ((m_file.size() - HEADER_SIZE) / 2) + HEADER_SIZE);
} else {
CHECK_VALUE(filesize, m_file.size());
}
CHECK_VALUES(m_resolution, 2000, 4000);
CHECK_VALUE(datasize % RECORD_SIZE, 0);
CHECK_VALUE(m_duration % m_record_count, 0);
//qDebug().noquote() << m_file.fileName() << ts(m_timestamp) << dur(m_duration * 1000L) << ":" << m_record_count << "records @" << m_resolution << "ms";
return true;
}
QList<ViatomFile::Record> ViatomFile::ReadData()
{
QByteArray data = m_file.readAll();
QDataStream in(data);
in.setByteOrder(QDataStream::LittleEndian);
QList<ViatomFile::Record> records;
// Read all Pulse, SPO2 and Motion data
do {
ViatomFile::Record rec;
in >> rec.spo2 >> rec.hr >> rec.oximetry_invalid >> rec.motion >> rec.vibration;
CHECK_VALUES(rec.oximetry_invalid, 0, 0xFF);
CHECK_VALUES(rec.vibration, 0, 0x80); // 0x80 when vibration is triggered
if (rec.oximetry_invalid == 0xFF) {
CHECK_VALUE(rec.spo2, 0xFF);
CHECK_VALUE(rec.hr, 0xFF);
}
records.append(rec);
} while (!in.atEnd());
// It turns out 2s files are actually just double-reported samples!
if (m_resolution == 2000) {
QList<ViatomFile::Record> dedup;
bool all_are_duplicated = true;
CHECK_VALUE(records.size() % 2, 0);
for (int i = 0; i < records.size(); i += 2) {
auto & a = records.at(i);
auto & b = records.at(i+1);
if (a.spo2 != b.spo2
|| a.hr != b.hr
|| a.oximetry_invalid != b.oximetry_invalid
|| a.motion != b.motion
|| a.vibration != b.vibration) {
all_are_duplicated = false;
break;
}
dedup.append(a);
}
CHECK_VALUE(all_are_duplicated, true);
if (all_are_duplicated) {
// Return the deduplicated list.
records = dedup;
}
}
CHECK_VALUE(duration() / records.size(), 4); // We've only seen 4s true resolution so far.
return records;
}