diff --git a/quickevent/app/quickevent/CMakeLists.txt b/quickevent/app/quickevent/CMakeLists.txt index e7a89050e..5368f5fe7 100644 --- a/quickevent/app/quickevent/CMakeLists.txt +++ b/quickevent/app/quickevent/CMakeLists.txt @@ -109,6 +109,9 @@ add_executable(quickevent plugins/Event/src/services/qx/qxlateregistrationswidget.ui plugins/Event/src/services/qx/runchangedialog.h plugins/Event/src/services/qx/runchangedialog.cpp plugins/Event/src/services/qx/runchangedialog.ui plugins/Event/src/services/qx/runchange.h plugins/Event/src/services/qx/runchange.cpp + plugins/Event/src/services/punchingtest/punchingtestservice.h plugins/Event/src/services/punchingtest/punchingtestservice.cpp + plugins/Event/src/services/punchingtest/punchingtestservicewidget.h plugins/Event/src/services/punchingtest/punchingtestservicewidget.cpp + plugins/Event/src/services/punchingtest/punchingtestservicewidget.ui plugins/Oris/src/chooseoriseventdialog.cpp plugins/Oris/src/chooseoriseventdialog.ui diff --git a/quickevent/app/quickevent/plugins/Event/src/eventplugin.cpp b/quickevent/app/quickevent/plugins/Event/src/eventplugin.cpp index 08f74624c..32e500ba3 100644 --- a/quickevent/app/quickevent/plugins/Event/src/eventplugin.cpp +++ b/quickevent/app/quickevent/plugins/Event/src/eventplugin.cpp @@ -13,6 +13,7 @@ #include "services/serviceswidget.h" #include "services/emmaclient.h" #include "services/qx/qxclientservice.h" +#include "services/punchingtest/punchingtestservice.h" #include #include @@ -437,6 +438,9 @@ void EventPlugin::onInstalled() auto shvapi_client = new services::qx::QxClientService(this); services::Service::addService(shvapi_client); + auto *punching_test = new services::PunchingTestService(this); + services::Service::addService(punching_test); + { m_servicesDockWidget = new qff::DockWidget(nullptr); m_servicesDockWidget->setObjectName("servicesDockWidget"); diff --git a/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.cpp b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.cpp new file mode 100644 index 000000000..0f228fec4 --- /dev/null +++ b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.cpp @@ -0,0 +1,256 @@ +#include "punchingtestservice.h" +#include "punchingtestservicewidget.h" + +#include "../../eventplugin.h" + +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include + +using qf::gui::framework::getPlugin; + +namespace Event::services { + +PunchingTestService::PunchingTestService(QObject *parent) + : Super(serviceName(), parent) +{ +} + +QString PunchingTestService::serviceName() +{ + return QStringLiteral("PunchingTest"); +} + +QString PunchingTestService::serviceDisplayName() const +{ + return tr("Punching Test"); +} + +void PunchingTestService::run() +{ + PunchingTestServiceSettings ss = settings(); + int interval_sec = ss.punchInterval(); + if (interval_sec <= 0) + interval_sec = 10; + + if (!m_timer) { + m_timer = new QTimer(this); + connect(m_timer, &QTimer::timeout, this, &PunchingTestService::onTimerTick); + } + m_timer->start(interval_sec * 1000); + setStatusMessage(tr("Running, interval: %1 s").arg(interval_sec)); + Super::run(); +} + +void PunchingTestService::stop() +{ + if (m_timer) + m_timer->stop(); + Super::stop(); +} + +void PunchingTestService::onTimerTick() +{ + auto *event_plugin = getPlugin(); + if (!event_plugin->isEventOpen()) { + setStatusMessage(tr("No event open")); + return; + } + int stage_id = event_plugin->currentStageId(); + + qf::core::sql::Query q; + if (!q.exec(QStringLiteral( + "SELECT id, siId, startTimeMs FROM runs" + " WHERE stageId=%1" + " AND isRunning" + " AND siId>0" + " AND (finishTimeMs IS NULL OR finishTimeMs=0)").arg(stage_id))) { + qfWarning() << "PunchingTestService: cannot query runs"; + return; + } + + QList candidates; + while (q.next()) + candidates << QVariantList{q.value(0), q.value(1), q.value(2)}; + + if (candidates.isEmpty()) { + setStatusMessage(tr("No eligible runners left")); + return; + } + + int ix = static_cast(QRandomGenerator::global()->bounded(static_cast(candidates.size()))); + const auto &cand = candidates[ix]; + int run_id = cand[0].toInt(); + int si_id = cand[1].toInt(); + int start_time_ms = cand[2].toInt(); // ms relative to stage start + + auto *runs_plugin = getPlugin(); + quickevent::core::CourseDef course = runs_plugin->courseCodesForRunId(run_id); + QVariantList codes = course.codes(); + + int stage_start_ms = event_plugin->stageStartMsec(stage_id); + + if (start_time_ms <= 0) { + // Runner has no drawn start — place randomly within the past 60 minutes + start_time_ms = static_cast(QRandomGenerator::global()->bounded(60U * 60U * 1000U)); + } + + int abs_start_ms = stage_start_ms + start_time_ms; + // Finish 20..30 minutes after start + int running_ms = (20 * 60 + static_cast(QRandomGenerator::global()->bounded(10U * 60U))) * 1000; + int abs_finish_ms = abs_start_ms + running_ms; + + // SI card times are in seconds within 12-hour AM window (0..43199) + static constexpr int SI_HALF_DAY_SEC = 12 * 3600; + auto toSiSec = [](int abs_ms) { + // Use positive modulo to handle pre-midnight edge cases + return ((abs_ms / 1000) % SI_HALF_DAY_SEC + SI_HALF_DAY_SEC) % SI_HALF_DAY_SEC; + }; + + int si_start_sec = toSiSec(abs_start_ms); + int si_finish_sec = toSiSec(abs_finish_ms); + + auto &rng = *QRandomGenerator::global(); + const PunchingTestServiceSettings ss = settings(); + + if (rng.bounded(static_cast(ss.unknownCardRate())) == 0) + si_id = 1000000 + static_cast(rng.bounded(8000000U)); + + if (rng.bounded(static_cast(ss.missingStartRate())) == 0) + si_start_sec = siut::SICard::INVALID_SI_TIME; + + if (rng.bounded(static_cast(ss.missingFinishRate())) == 0) + si_finish_sec = siut::SICard::INVALID_SI_TIME; + + // Check time: respect the event's "Card check" max-advance setting. + // When enabled: 1/badCheckRate chance the runner checked too early (triggers bad-check). + // When disabled: natural 1–2 minute window, no bad-check possible. + int check_offset_sec; + if (auto cfg = event_plugin->eventConfig()->maximumCardCheckAdvanceSec(); cfg.has_value()) { + int max_sec = cfg.value(); + if (rng.bounded(static_cast(ss.badCheckRate())) == 0) { + // Bad check: checked too early — [max+1, max*3] seconds before start + check_offset_sec = max_sec + 1 + static_cast(rng.bounded(static_cast(max_sec * 2))); + } else { + check_offset_sec = 1 + static_cast(rng.bounded(static_cast(max_sec))); + } + } else { + check_offset_sec = 60 + static_cast(rng.bounded(60U)); // 1–2 minutes + } + int si_check_sec = toSiSec(abs_start_ms - check_offset_sec * 1000); + + quickevent::core::CodeDef start_cd = course.startCode(); + quickevent::core::CodeDef finish_cd = course.finishCode(); + int n_controls = codes.size(); + + // Build per-leg distances: [start→ctrl0, ctrl0→ctrl1, …, ctrl[n-1]→finish] + // Fall back to unit weights when GPS coordinates are absent. + double prev_lat = start_cd.latitude(), prev_lon = start_cd.longitude(); + bool has_coords = (prev_lat != 0.0 || prev_lon != 0.0); + + QVector leg_dist; + leg_dist.reserve(n_controls + 1); + for (int k = 0; k < n_controls; ++k) { + quickevent::core::CodeDef cd(codes[k].toMap()); + if (has_coords) { + double clat = cd.latitude(), clon = cd.longitude(); + leg_dist << Runs::RunsPlugin::latlng_distance(prev_lat, prev_lon, clat, clon); + prev_lat = clat; + prev_lon = clon; + } else { + leg_dist << 1.0; + } + } + if (has_coords) + leg_dist << Runs::RunsPlugin::latlng_distance(prev_lat, prev_lon, finish_cd.latitude(), finish_cd.longitude()); + else + leg_dist << 1.0; + + // Noisy weights: leg_distance × U[0.8, 1.2] — simulates uneven terrain/navigation + QVector weights; + weights.reserve(leg_dist.size()); + double total_weight = 0.0; + for (double d : leg_dist) { + double w = (d > 0.0 ? d : 1.0) * (0.8 + rng.generateDouble() * 0.4); + weights << w; + total_weight += w; + } + + QVariantList punches; + double cumulative_w = 0.0; + for (int k = 0; k < n_controls; ++k) { + cumulative_w += weights[k]; + if (rng.bounded(static_cast(ss.mispunchRate())) == 0) + continue; + quickevent::core::CodeDef cd(codes[k].toMap()); + double t = cumulative_w / total_weight; + int ctrl_ms = abs_start_ms + static_cast(t * (abs_finish_ms - abs_start_ms)); + int ctrl_sec = (ctrl_ms / 1000) % SI_HALF_DAY_SEC; + siut::SIPunch punch(cd.code(), ctrl_sec); + punches << QVariant(static_cast(punch)); + } + + // Extra punch: a wrong control inserted at a chronologically correct position + if (rng.bounded(static_cast(ss.extraPunchRate())) == 0) { + QSet course_codes; + for (const auto &v : punches) + course_codes.insert(siut::SIPunch(v.toMap()).code()); + int extra_code = 0; + const int code_range = quickevent::core::CodeDef::PUNCH_CODE_MAX + - quickevent::core::CodeDef::PUNCH_CODE_MIN + 1; + for (int attempt = 0; attempt < 20 && extra_code == 0; ++attempt) { + int candidate = quickevent::core::CodeDef::PUNCH_CODE_MIN + + static_cast(rng.bounded(static_cast(code_range))); + if (!course_codes.contains(candidate)) + extra_code = candidate; + } + if (extra_code > 0) { + double t = rng.generateDouble(); + int extra_ms = abs_start_ms + static_cast(t * (abs_finish_ms - abs_start_ms)); + int extra_sec = (extra_ms / 1000) % SI_HALF_DAY_SEC; + siut::SIPunch extra_punch(extra_code, extra_sec); + // Insert at the position that keeps the list in chronological order + int insert_at = punches.size(); + for (int i = 0; i < punches.size(); ++i) { + if (extra_sec < siut::SIPunch(punches[i].toMap()).time()) { + insert_at = i; + break; + } + } + punches.insert(insert_at, QVariant(static_cast(extra_punch))); + } + } + + siut::SICard card; + card.setCardNumber(si_id); + card.setCheckTime(si_check_sec); + card.setStartTime(si_start_sec); + card.setFinishTime(si_finish_sec); + card.setPunches(punches); + + setStatusMessage(tr("Card SI %1, %2 controls").arg(si_id).arg(punches.size())); + + getPlugin()->emitSiTaskFinished( + static_cast(siut::SiTask::Type::CardRead), + QVariant(static_cast(card))); +} + +qf::gui::framework::DialogWidget *PunchingTestService::createDetailWidget() +{ + return new PunchingTestServiceWidget(); +} + +} // namespace Event::services diff --git a/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.h b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.h new file mode 100644 index 000000000..b8aff7d4e --- /dev/null +++ b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservice.h @@ -0,0 +1,52 @@ +#pragma once + +#include "../service.h" + +class QTimer; + +namespace Event { +namespace services { + +class PunchingTestServiceSettings : public ServiceSettings +{ + using Super = ServiceSettings; + + QF_VARIANTMAP_FIELD2(int, p, setP, unchInterval, 10) // seconds between ticks + + // Per-card imperfections: probability = 1/N + QF_VARIANTMAP_FIELD2(int, u, setU, nknownCardRate, 100) // wrong SI number + QF_VARIANTMAP_FIELD2(int, m, setM, issingStartRate, 80) // no start punch + QF_VARIANTMAP_FIELD2(int, m, setM, issingFinishRate, 250) // no finish punch + QF_VARIANTMAP_FIELD2(int, e, setE, xtraPunchRate, 30) // extra wrong control + QF_VARIANTMAP_FIELD2(int, b, setB, adCheckRate, 1800) // check outside window + + // Per-control imperfection: probability = 1/N + QF_VARIANTMAP_FIELD2(int, m, setM, ispunchRate, 930) // missed control + +public: + PunchingTestServiceSettings(const QVariantMap &o = QVariantMap()) : Super(o) {} +}; + +class PunchingTestService : public Service +{ + Q_OBJECT + using Super = Service; +public: + explicit PunchingTestService(QObject *parent); + + void run() override; + void stop() override; + + PunchingTestServiceSettings settings() const { return PunchingTestServiceSettings(m_settings); } + static QString serviceName(); + QString serviceDisplayName() const override; + +private: + void onTimerTick(); + qf::gui::framework::DialogWidget *createDetailWidget() override; + +private: + QTimer *m_timer = nullptr; +}; + +}} // namespace Event::services diff --git a/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.cpp b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.cpp new file mode 100644 index 000000000..55550c9b2 --- /dev/null +++ b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.cpp @@ -0,0 +1,69 @@ +#include "punchingtestservicewidget.h" +#include "ui_punchingtestservicewidget.h" +#include "punchingtestservice.h" + +#include "../service.h" + +#include + +#include + +namespace Event::services { + +PunchingTestServiceWidget::PunchingTestServiceWidget(QWidget *parent) + : Super(parent) + , ui(new Ui::PunchingTestServiceWidget) +{ + setPersistentSettingsId("PunchingTestServiceWidget"); + ui->setupUi(this); + + PunchingTestService *svc = service(); + if (svc) { + PunchingTestServiceSettings ss = svc->settings(); + ui->edPunchInterval->setValue(ss.punchInterval()); + ui->edUnknownCardRate->setValue(ss.unknownCardRate()); + ui->edMissingStartRate->setValue(ss.missingStartRate()); + ui->edMissingFinishRate->setValue(ss.missingFinishRate()); + ui->edExtraPunchRate->setValue(ss.extraPunchRate()); + ui->edBadCheckRate->setValue(ss.badCheckRate()); + ui->edMispunchRate->setValue(ss.mispunchRate()); + } +} + +PunchingTestServiceWidget::~PunchingTestServiceWidget() +{ + delete ui; +} + +bool PunchingTestServiceWidget::acceptDialogDone(int result) +{ + if (result == QDialog::Accepted) + saveSettings(); + return true; +} + +PunchingTestService *PunchingTestServiceWidget::service() +{ + auto *svc = qobject_cast( + Service::serviceByName(PunchingTestService::serviceName())); + QF_ASSERT(svc, PunchingTestService::serviceName() + " doesn't exist", return nullptr); + return svc; +} + +void PunchingTestServiceWidget::saveSettings() +{ + PunchingTestService *svc = service(); + if (svc) { + PunchingTestServiceSettings ss = svc->settings(); + ss.setPunchInterval(ui->edPunchInterval->value()); + ss.setUnknownCardRate(ui->edUnknownCardRate->value()); + ss.setMissingStartRate(ui->edMissingStartRate->value()); + ss.setMissingFinishRate(ui->edMissingFinishRate->value()); + ss.setExtraPunchRate(ui->edExtraPunchRate->value()); + ss.setBadCheckRate(ui->edBadCheckRate->value()); + ss.setMispunchRate(ui->edMispunchRate->value()); + svc->setSettings(ss); + } +} + +} // namespace Event::services diff --git a/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.h b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.h new file mode 100644 index 000000000..9c2386cb0 --- /dev/null +++ b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace Event { +namespace services { + +namespace Ui { + class PunchingTestServiceWidget; +} + +class PunchingTestService; + +class PunchingTestServiceWidget : public qf::gui::framework::DialogWidget +{ + Q_OBJECT + using Super = qf::gui::framework::DialogWidget; +public: + explicit PunchingTestServiceWidget(QWidget *parent = nullptr); + ~PunchingTestServiceWidget() override; + +private: + bool acceptDialogDone(int result) override; + PunchingTestService *service(); + void saveSettings(); + +private: + Ui::PunchingTestServiceWidget *ui; +}; + +}} // namespace Event::services diff --git a/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.ui b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.ui new file mode 100644 index 000000000..48a3fc1b2 --- /dev/null +++ b/quickevent/app/quickevent/plugins/Event/src/services/punchingtest/punchingtestservicewidget.ui @@ -0,0 +1,249 @@ + + + Event::services::PunchingTestServiceWidget + + + + 0 + 0 + 420 + 380 + + + + Punching Test + + + + + + Generates fake SI card readouts for registered runners at a fixed interval. Each readout simulates realistic imperfections — missing punches, extra controls, unknown card numbers, and bad check times — to stress-test results processing without physical SI cards. + + + true + + + + + + + QFrame::HLine + + + QFrame::Sunken + + + + + + + + + Punch interval + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + s + + + 1 + + + 3600 + + + 10 + + + + + + + + + Per-card imperfections (1 in N probability) + + + + + + Unknown card number (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 100 + + + + + + + Missing start punch (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 80 + + + + + + + Missing finish punch (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 250 + + + + + + + Extra wrong control (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 30 + + + + + + + Bad check time (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 1800 + + + + + + + + + + Per-control imperfections (1 in N probability) + + + + + + Mispunch / missed control (1 in) + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 1 + + + 99999 + + + 930 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + edPunchInterval + edUnknownCardRate + edMissingStartRate + edMissingFinishRate + edExtraPunchRate + edBadCheckRate + edMispunchRate + + + + diff --git a/quickevent/app/quickevent/plugins/Runs/src/runsplugin.cpp b/quickevent/app/quickevent/plugins/Runs/src/runsplugin.cpp index 64f8270de..b5e3ccbe7 100644 --- a/quickevent/app/quickevent/plugins/Runs/src/runsplugin.cpp +++ b/quickevent/app/quickevent/plugins/Runs/src/runsplugin.cpp @@ -174,8 +174,7 @@ int RunsPlugin::courseForRun(int run_id) } return courseForRun_Classic(run_id); } -namespace { -int latlng_distance(double lat1, double lng1, double lat2, double lng2) +int RunsPlugin::latlng_distance(double lat1, double lng1, double lat2, double lng2) { /// http://www.movable-type.co.uk/scripts/latlong.html if(qFuzzyIsNull(lng2 - lng1) && qFuzzyIsNull(lat2 - lat1)) @@ -194,7 +193,6 @@ int latlng_distance(double lat1, double lng1, double lat2, double lng2) double d = std::sqrt(x*x + y*y) * R; return static_cast(std::ceil(d)); } -} quickevent::core::CourseDef RunsPlugin::courseCodesForRunId(int run_id) { qfLogFuncFrame() << "run id:" << run_id; diff --git a/quickevent/app/quickevent/plugins/Runs/src/runsplugin.h b/quickevent/app/quickevent/plugins/Runs/src/runsplugin.h index 3df7c594f..4332742a4 100644 --- a/quickevent/app/quickevent/plugins/Runs/src/runsplugin.h +++ b/quickevent/app/quickevent/plugins/Runs/src/runsplugin.h @@ -56,6 +56,7 @@ class RunsPlugin : public qf::gui::framework::Plugin int courseForRelay(int relay_number, int leg); quickevent::core::CourseDef courseCodesForRunId(int run_id); quickevent::core::CourseDef courseForCourseId(int course_id); + static int latlng_distance(double lat1, double lng1, double lat2, double lng2); Q_INVOKABLE int cardForRun(int run_id); qf::core::utils::TreeTable currentStageResultsTable(const QString &class_filter = QString(), int max_competitors_in_class = 0, bool exclude_disq = false);