15.1.616. tablet_qt/tasks/cardinalexpdetthreshold.cpp

/*
    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
*/

// Consider: linear v. logarithmic volume; https://doc.qt.io/qt-6.5/qaudio.html#convertVolume

// #define DEBUG_STEP_DETAIL

#include "cardinalexpdetthreshold.h"
#include <QGraphicsScene>
#include <QPushButton>
#include <QTimer>
#include "common/textconst.h"
#include "db/ancillaryfunc.h"
#include "db/dbnestabletransaction.h"
#include "graphics/graphicsfunc.h"
#include "lib/convert.h"
#include "lib/soundfunc.h"
#include "lib/timerfunc.h"
#include "lib/uifunc.h"
#include "maths/ccrandom.h"
#include "maths/mathfunc.h"
#include "questionnairelib/questionnaire.h"
#include "questionnairelib/questionnairefunc.h"
#include "questionnairelib/qulineeditdouble.h"
#include "questionnairelib/qulineeditinteger.h"
#include "questionnairelib/qumcq.h"
#include "questionnairelib/qupage.h"
#include "questionnairelib/qutext.h"
#include "tasklib/taskfactory.h"
#include "tasklib/taskregistrar.h"
#include "taskxtra/cardinalexpdetcommon.h"
#include "taskxtra/cardinalexpdetthresholdtrial.h"
using namespace cardinalexpdetcommon;  // lots...
using ccrandom::coin;
using ccrandom::randomRealIncUpper;
using convert::msFromSec;
using graphicsfunc::ButtonAndProxy;
using graphicsfunc::makeImage;
using graphicsfunc::makeText;
using graphicsfunc::makeTextButton;
using mathfunc::mean;


// ============================================================================
// Constants
// ============================================================================

const QString CardinalExpDetThreshold::CARDINALEXPDETTHRESHOLD_TABLENAME(
        "cardinal_expdetthreshold");

// Fieldnames: config
const QString FN_MODALITY("modality");
const QString FN_TARGET_NUMBER("target_number");
const QString FN_BACKGROUND_FILENAME("background_filename");
const QString FN_TARGET_FILENAME("target_filename");
const QString FN_VISUAL_TARGET_DURATION_S("visual_target_duration_s");
const QString FN_BACKGROUND_INTENSITY("background_intensity");
const QString FN_START_INTENSITY_MIN("start_intensity_min");
const QString FN_START_INTENSITY_MAX("start_intensity_max");
const QString FN_INITIAL_LARGE_INTENSITY_STEP("initial_large_intensity_step");
const QString FN_MAIN_SMALL_INTENSITY_STEP("main_small_intensity_step");
const QString FN_NUM_TRIALS_IN_MAIN_SEQUENCE("num_trials_in_main_sequence");
const QString FN_P_CATCH_TRIAL("p_catch_trial");
const QString FN_PROMPT("prompt");
const QString FN_ITI_S("iti_s");
// Fieldnames: results
const QString FN_FINISHED("finished");
const QString FN_INTERCEPT("intercept");
const QString FN_SLOPE("slope");
const QString FN_K("k");
const QString FN_THETA("theta");

// Defaults
const qreal DEFAULT_VISUAL_TARGET_DURATION_S = 1.0;
const qreal DEFAULT_BACKGROUND_INTENSITY = 1.0;
const qreal DEFAULT_START_INTENSITY_MIN = 0.9;
const qreal DEFAULT_START_INTENSITY_MAX = 1.0;
const qreal DEFAULT_INITIAL_LARGE_INTENSITY_STEP = 0.1;
const qreal DEFAULT_MAIN_SMALL_INTENSITY_STEP = 0.01;
const int DEFAULT_NUM_TRIALS_IN_MAIN_SEQUENCE = 14;
const qreal DEFAULT_P_CATCH_TRIAL = 0.2;
const qreal DEFAULT_ITI_S = 0.2;

// Tags
const QString TAG_P2("p2");
const QString TAG_P3("p3");
const QString TAG_AUDITORY("a");
const QString TAG_VISUAL("v");
const QString TAG_WARNING_MIN_MAX("mm");

// Other
const int DP = 3;


// ============================================================================
// Factory method
// ============================================================================

void initializeCardinalExpDetThreshold(TaskFactory& factory)
{
    static TaskRegistrar<CardinalExpDetThreshold> registered(factory);
}


// ============================================================================
// CardinalExpectationDetection
// ============================================================================

CardinalExpDetThreshold::CardinalExpDetThreshold(
        CamcopsApp& app, DatabaseManager& db, const int load_pk) :
    Task(app, db, CARDINALEXPDETTHRESHOLD_TABLENAME, false, false, false)  // ... anon, clin, resp
{
    // Config
    addField(FN_MODALITY, QMetaType::fromType<int>());
    addField(FN_TARGET_NUMBER, QMetaType::fromType<int>());
    addField(FN_BACKGROUND_FILENAME, QMetaType::fromType<QString>());  // set automatically
    addField(FN_TARGET_FILENAME, QMetaType::fromType<QString>());  // set automatically
    addField(FN_VISUAL_TARGET_DURATION_S, QMetaType::fromType<double>());
    addField(FN_BACKGROUND_INTENSITY, QMetaType::fromType<double>());
    addField(FN_START_INTENSITY_MIN, QMetaType::fromType<double>());
    addField(FN_START_INTENSITY_MAX, QMetaType::fromType<double>());
    addField(FN_INITIAL_LARGE_INTENSITY_STEP, QMetaType::fromType<double>());
    addField(FN_MAIN_SMALL_INTENSITY_STEP, QMetaType::fromType<double>());
    addField(FN_NUM_TRIALS_IN_MAIN_SEQUENCE, QMetaType::fromType<int>());
    addField(FN_P_CATCH_TRIAL, QMetaType::fromType<double>());
    addField(FN_PROMPT, QMetaType::fromType<QString>());
    addField(FN_ITI_S, QMetaType::fromType<double>());
    // Results
    addField(FN_FINISHED, QMetaType::fromType<bool>());
    addField(FN_INTERCEPT, QMetaType::fromType<double>());
    addField(FN_SLOPE, QMetaType::fromType<double>());
    addField(FN_K, QMetaType::fromType<double>());
    addField(FN_THETA, QMetaType::fromType<double>());

    load(load_pk);

    if (load_pk == dbconst::NONEXISTENT_PK) {
        // Default values:
        setValue(FN_VISUAL_TARGET_DURATION_S, DEFAULT_VISUAL_TARGET_DURATION_S, false);
        setValue(FN_BACKGROUND_INTENSITY, DEFAULT_BACKGROUND_INTENSITY, false);
        setValue(FN_START_INTENSITY_MIN, DEFAULT_START_INTENSITY_MIN, false);
        setValue(FN_START_INTENSITY_MAX, DEFAULT_START_INTENSITY_MAX, false);
        setValue(FN_INITIAL_LARGE_INTENSITY_STEP, DEFAULT_INITIAL_LARGE_INTENSITY_STEP, false);
        setValue(FN_MAIN_SMALL_INTENSITY_STEP, DEFAULT_MAIN_SMALL_INTENSITY_STEP, false);
        setValue(FN_NUM_TRIALS_IN_MAIN_SEQUENCE, DEFAULT_NUM_TRIALS_IN_MAIN_SEQUENCE, false);
        setValue(FN_P_CATCH_TRIAL, DEFAULT_P_CATCH_TRIAL, false);
        setValue(FN_ITI_S, DEFAULT_ITI_S, false);
    }

    // Internal data
    m_current_trial = -1;
    m_current_trial_ignoring_catch_trials = -1;
    m_trial_last_y_b4_first_n = -1;
}


CardinalExpDetThreshold::~CardinalExpDetThreshold()
{
    // Necessary: for rationale, see QuAudioPlayer::~QuAudioPlayer()
    soundfunc::finishMediaPlayer(m_player_background);
    soundfunc::finishMediaPlayer(m_player_target);
}


// ============================================================================
// Class info
// ============================================================================

QString CardinalExpDetThreshold::shortname() const
{
    return "Cardinal_ExpDetThreshold";
}


QString CardinalExpDetThreshold::longname() const
{
    return tr("Cardinal RN — ExpDet-Threshold task");
}


QString CardinalExpDetThreshold::description() const
{
    return tr("Rapid assessment of auditory/visual thresholds "
              "(for expectation–detection task).");
}


// ============================================================================
// Ancillary management
// ============================================================================

QStringList CardinalExpDetThreshold::ancillaryTables() const
{
    return QStringList{CardinalExpDetThresholdTrial::TRIAL_TABLENAME};
}


QString CardinalExpDetThreshold::ancillaryTableFKToTaskFieldname() const
{
    return CardinalExpDetThresholdTrial::FN_FK_TO_TASK;
}


void CardinalExpDetThreshold::loadAllAncillary(const int pk)
{
    const OrderBy order_by{{CardinalExpDetThresholdTrial::FN_TRIAL, true}};
    ancillaryfunc::loadAncillary<CardinalExpDetThresholdTrial,
                                 CardinalExpDetThresholdTrialPtr>(
                m_trials, m_app, m_db,
                CardinalExpDetThresholdTrial::FN_FK_TO_TASK, order_by, pk);
}


QVector<DatabaseObjectPtr> CardinalExpDetThreshold::getAncillarySpecimens() const
{
    return QVector<DatabaseObjectPtr>{
        CardinalExpDetThresholdTrialPtr(new CardinalExpDetThresholdTrial(m_app, m_db)),
    };
}


QVector<DatabaseObjectPtr> CardinalExpDetThreshold::getAllAncillary() const
{
    QVector<DatabaseObjectPtr> ancillaries;
    for (const CardinalExpDetThresholdTrialPtr& trial : m_trials) {
        ancillaries.append(trial);
    }
    return ancillaries;
}


// ============================================================================
// Instance info
// ============================================================================

bool CardinalExpDetThreshold::isComplete() const
{
    return valueBool(FN_FINISHED);
}


QStringList CardinalExpDetThreshold::summary() const
{
    return QStringList{
        QString("Target: <b>%1</b>.").arg(getTargetName()),
        QString("x75 [intensity for which p(detect) = 0.75]: <b>%1</b>").arg(
                    convert::prettyValue(x75(), DP)),
    };
}


QStringList CardinalExpDetThreshold::detail() const
{
    QStringList lines = completenessInfo() + summary();
    lines.append("\n");
    lines += recordSummaryLines();
    lines.append("\n");
    lines.append("Trials:");
    for (const CardinalExpDetThresholdTrialPtr& trial : m_trials) {
        lines.append(trial->recordSummaryCSVString());
    }
    lines.append("\n");
    LogisticDescriptives ld = calculateFit();
    lines.append(QString("Logistic parameters, recalculated now: intercept=%1,"
                         " slope=%2").arg(ld.intercept()).arg(ld.slope()));
    return lines;
}


OpenableWidget* CardinalExpDetThreshold::editor(const bool read_only)
{
    // ------------------------------------------------------------------------
    // OK to edit?
    // ------------------------------------------------------------------------
    if (read_only) {
        qWarning() << "Task not editable! Shouldn't have got here.";
        return nullptr;
    }

    // ------------------------------------------------------------------------
    // Configure the task using a Questionnaire
    // ------------------------------------------------------------------------

    const QString TX_CONFIG_TITLE(tr("Configure ExpDetThreshold task"));
    const NameValueOptions modality_options{
        {txtAuditory(), MODALITY_AUDITORY},
        {txtVisual(), MODALITY_VISUAL},
    };
    const NameValueOptions target_options_auditory{
        {ExpDetTextConst::auditoryTarget0(), 0},
        {ExpDetTextConst::auditoryTarget1(), 1},
    };
    const NameValueOptions target_options_visual{
        {ExpDetTextConst::visualTarget0(), 0},
        {ExpDetTextConst::visualTarget1(), 1},
    };

    // const int no_max = std::numeric_limits<int>::max();
    const QString warning_min_max(tr(
            "WARNING: cannot proceed: must satisfy "
            "min start intensity <= max start intensity"));

    auto text = [](const QString& t) -> QuElement* {
        return new QuText(t);
    };
    auto boldtext = [](const QString& t) -> QuElement* {
        return (new QuText(t))->setBold(true);
    };
    auto mcq = [this](const QString& fieldname,
                      const NameValueOptions& options) -> QuElement* {
        return new QuMcq(fieldRef(fieldname), options);
    };

    QuPagePtr page1((new QuPage{
        boldtext(tr(
            "Set your device’s brightness and volume BEFORE running this "
            "task, and DO NOT ALTER THEM in between runs or before completing "
            "the main Expectation–Detection task. Also, try to keep the "
            "lighting and background noise constant throughout."
        )),
        text(tr(
            "Before you run the Expectation–Detection task for a given "
            "subject, please run this task FOUR times to determine the "
            "subject’s threshold for each of two auditory stimuli (tone, "
            "voice) and each of two auditory stimuli (circle, word)."
        )),
        text(tr(
            "Then, make a note of the 75% (“x75”) threshold intensities for "
            "each stimulus, and start the Expectation–Detection task (which "
            "only needs to be run once). It will ask you for these four "
            "intensities."
        )),
        boldtext(tr("Choose a modality:")),
        mcq(FN_MODALITY, modality_options),
    })->setTitle(TX_CONFIG_TITLE + " (1)"));

    QuPagePtr page2((new QuPage{
        boldtext(tr("Choose a target stimulus:")),
        mcq(FN_TARGET_NUMBER, target_options_auditory)->addTag(TAG_AUDITORY),
        mcq(FN_TARGET_NUMBER, target_options_visual)->addTag(TAG_VISUAL),
    })->setTitle(TX_CONFIG_TITLE + " (2)")->addTag(TAG_P2));

    const qreal zero = 0.0;
    const qreal one = 1.0;

    QuPagePtr page3((new QuPage{
        text(tr("Intensities and probabilities are in the range 0–1.")),
        questionnairefunc::defaultGridRawPointer({
            {ExpDetTextConst::configVisualTargetDurationS(),
             new QuLineEditDouble(fieldRef(FN_VISUAL_TARGET_DURATION_S), 0.1, 10.0)},
            {tr("Background intensity (usually 1.0)"),
             new QuLineEditDouble(fieldRef(FN_BACKGROUND_INTENSITY), zero, one)},
            {tr("Minimum starting intensity (e.g. 0.9)"),
             new QuLineEditDouble(fieldRef(FN_START_INTENSITY_MIN), zero, one)},
            {tr("Maximum starting intensity (e.g. 1.0)"),
             new QuLineEditDouble(fieldRef(FN_START_INTENSITY_MAX), zero, one)},
            {tr("Initial, large, intensity step (e.g. 0.1)"),
             new QuLineEditDouble(fieldRef(FN_INITIAL_LARGE_INTENSITY_STEP), zero, one)},
            {tr("Main, small, intensity step (e.g. 0.01)"),
             new QuLineEditDouble(fieldRef(FN_MAIN_SMALL_INTENSITY_STEP), zero, one)},
            {tr("Number of trials in the main test sequence (e.g. 14)"),
             new QuLineEditInteger(fieldRef(FN_NUM_TRIALS_IN_MAIN_SEQUENCE), 0, 100)},
            {tr("Probability of a catch trial (e.g. 0.2)"),
             new QuLineEditDouble(fieldRef(FN_P_CATCH_TRIAL), zero, one)},
            {tr("Intertrial interval (s) (e.g. 0.2)"),
             new QuLineEditDouble(fieldRef(FN_ITI_S), zero, 100.0)},
        }),
        (new QuText(warning_min_max))
                        ->setWarning(true)
                        ->addTag(TAG_WARNING_MIN_MAX),
    })->setTitle(TX_CONFIG_TITLE + " (3)")->addTag(TAG_P3));

    m_questionnaire = new Questionnaire(m_app, {page1, page2, page3});
    m_questionnaire->setType(QuPage::PageType::Clinician);
    m_questionnaire->setReadOnly(read_only);
    m_questionnaire->setWithinChain(true);  // fast forward button, not stop

    connect(fieldRef(FN_MODALITY).data(), &FieldRef::valueChanged,
            this, &CardinalExpDetThreshold::validateQuestionnaire);
    connect(fieldRef(FN_START_INTENSITY_MIN).data(), &FieldRef::valueChanged,
            this, &CardinalExpDetThreshold::validateQuestionnaire);
    connect(fieldRef(FN_START_INTENSITY_MAX).data(), &FieldRef::valueChanged,
            this, &CardinalExpDetThreshold::validateQuestionnaire);

    connect(m_questionnaire.data(), &Questionnaire::cancelled,
            this, &CardinalExpDetThreshold::abort);
    connect(m_questionnaire.data(), &Questionnaire::completed,
            this, &CardinalExpDetThreshold::startTask);
    // Because our main m_widget isn't itself a questionnaire, we need to hook
    // up these, too:
    questionnairefunc::connectQuestionnaireToTask(m_questionnaire.data(), this);

    validateQuestionnaire();

    // ------------------------------------------------------------------------
    // If the config questionnaire is successful, we'll launch the main task;
    // prepare this too.
    // ------------------------------------------------------------------------

    m_scene = new QGraphicsScene(SCENE_RECT);
    m_scene->setBackgroundBrush(QBrush(SCENE_BACKGROUND));
    m_graphics_widget = makeGraphicsWidget(m_scene, SCENE_BACKGROUND,
                                           true, true);
    connect(m_graphics_widget.data(), &OpenableWidget::aborting,
            this, &CardinalExpDetThreshold::abort);

    m_widget = new OpenableWidget();

    // We start off by seeing the questionnaire:
    m_widget->setWidgetAsOnlyContents(m_questionnaire, 0, false, false);

    return m_widget;
}


// ============================================================================
// Config questionnaire internals
// ============================================================================

void CardinalExpDetThreshold::validateQuestionnaire()
{
    if (!m_questionnaire) {
        return;
    }

    // 1. Validation
    QVector<QuPage*> pages = m_questionnaire->getPages(false, TAG_P3);
    Q_ASSERT(pages.size() == 1);
    QuPage* page3 = pages.at(0);
    const bool duff_minmax = valueDouble(FN_START_INTENSITY_MAX) <
            valueDouble(FN_START_INTENSITY_MIN);
    m_questionnaire->setVisibleByTag(TAG_WARNING_MIN_MAX, duff_minmax,
                                     false, TAG_P3);
    page3->blockProgress(duff_minmax);

    // 2. Choice of target
    const bool auditory = isAuditory();
    m_questionnaire->setVisibleByTag(TAG_AUDITORY, auditory, false, TAG_P2);
    m_questionnaire->setVisibleByTag(TAG_VISUAL, !auditory, false, TAG_P2);
}


// ============================================================================
// Connection macros
// ============================================================================

// MUST USE Qt::QueuedConnection - see comments in clearScene()
#define CONNECT_BUTTON(b, funcname) \
    connect((b).button, &QPushButton::clicked, \
            this, &CardinalExpDetThreshold::funcname, \
            Qt::QueuedConnection)
// To use a Qt::ConnectionType parameter with a functor, we need a context
// See https://doc.qt.io/qt-6.5/qobject.html#connect-5
// That's the reason for the extra "this":
#define CONNECT_BUTTON_PARAM(b, funcname, param) \
    connect((b).button, &QPushButton::clicked, \
            this, std::bind(&CardinalExpDetThreshold::funcname, this, param), \
            Qt::QueuedConnection)


// ============================================================================
// Calculation/assistance functions for main task
// ============================================================================

QString CardinalExpDetThreshold::getDescriptiveModality() const
{
    const QVariant modality = value(FN_MODALITY);
    // can't use external constants in a switch statement
    if (modality.isNull()) {
        return TextConst::unknown();
    }
    if (modality.toInt() == MODALITY_AUDITORY) {
        return txtAuditory();
    }
    if (modality.toInt() == MODALITY_VISUAL) {
        return txtVisual();
    }
    return TextConst::unknown();
}


QString CardinalExpDetThreshold::getTargetName() const
{
    const QVariant modality = value(FN_MODALITY);
    const QVariant target_number = value(FN_TARGET_NUMBER);
    if (modality.isNull() || target_number.isNull()) {
        return TextConst::unknown();
    }
    if (modality.toInt() == MODALITY_AUDITORY) {
        switch (target_number.toInt()) {
        case 0:
            return ExpDetTextConst::auditoryTarget0();
        case 1:
            return ExpDetTextConst::auditoryTarget1();
        }
    } else if (modality.toInt() == MODALITY_VISUAL) {
        switch (target_number.toInt()) {
        case 0:
            return ExpDetTextConst::visualTarget0();
        case 1:
            return ExpDetTextConst::visualTarget1();
        }
    }
    return TextConst::unknown();
}


QVariant CardinalExpDetThreshold::x(const qreal p) const
{
    if (valueIsNull(FN_INTERCEPT) || valueIsNull(FN_SLOPE)) {
        return QVariant();
    }
    const qreal intercept = valueDouble(FN_INTERCEPT);
    const qreal slope = valueDouble(FN_SLOPE);
    LogisticDescriptives ld(intercept, slope);  // coefficients already known
    return ld.x(p);
}


QVariant CardinalExpDetThreshold::x75() const
{
    return x(0.75);
}


bool CardinalExpDetThreshold::haveWeJustReset() const
{
    const int last_trial = m_current_trial - 1;
    if (last_trial < 0 || last_trial >= m_trials.size()) {
        return false;
    }
    return m_trials.at(last_trial)->wasCaughtOutReset();
}


bool CardinalExpDetThreshold::inInitialStepPhase() const
{
    return m_trial_last_y_b4_first_n < 0;
}


bool CardinalExpDetThreshold::lastTrialWasFirstNo() const
{
    if (m_trial_last_y_b4_first_n < 0 || m_current_trial < 0) {
        return false;
    }
    return (
        m_trials.at(m_current_trial)->trialNumIgnoringCatchTrials() ==
            m_trials.at(m_trial_last_y_b4_first_n)->trialNumIgnoringCatchTrials()
                + 2
    );
}


int CardinalExpDetThreshold::getNBackNonCatchTrialIndex(
        const int n, const int start_index) const
{
    Q_ASSERT(start_index >= 0 && start_index < m_trials.size());
    const int target = m_trials.at(start_index)->trialNumIgnoringCatchTrials() - n;
    for (int i = 0; i < m_trials.size(); ++i) {
        const CardinalExpDetThresholdTrialPtr& t = m_trials.at(i);
        if (t->targetPresented() && t->trialNumIgnoringCatchTrials() == target) {
            return i;
        }
    }
    return -1;
}


qreal CardinalExpDetThreshold::getIntensity() const
{
    Q_ASSERT(m_current_trial >= 0 && m_current_trial < m_trials.size());
    const qreal fail = -1.0;
    const CardinalExpDetThresholdTrialPtr& t = m_trials.at(m_current_trial);
    if (!t->targetPresented()) {
        return fail;
    }
    if (t->trialNum() == 0 || haveWeJustReset()) {
        // First trial, or we've just reset
        return randomRealIncUpper(valueDouble(FN_START_INTENSITY_MIN),
                                  valueDouble(FN_START_INTENSITY_MAX));
    }
    const int one_back = getNBackNonCatchTrialIndex(1, m_current_trial);
    Q_ASSERT(one_back >= 0);
    const CardinalExpDetThresholdTrialPtr& prev = m_trials.at(one_back);
    if (inInitialStepPhase()) {
        return prev->intensity() - valueDouble(FN_INITIAL_LARGE_INTENSITY_STEP);
    }
    if (lastTrialWasFirstNo()) {
        const int two_back = getNBackNonCatchTrialIndex(2, m_current_trial);
        Q_ASSERT(two_back >= 0);
        const CardinalExpDetThresholdTrialPtr& tb = m_trials.at(two_back);
        return mean(prev->intensity(), tb->intensity());
    }
    if (prev->yes()) {
        // In main phase. Detected stimulus last time; make it harder
        return prev->intensity() - valueDouble(FN_MAIN_SMALL_INTENSITY_STEP);
    }
    // In main phase. Didn't detect stimulus last time; make it easier
    return prev->intensity() + valueDouble(FN_MAIN_SMALL_INTENSITY_STEP);
}


bool CardinalExpDetThreshold::wantCatchTrial(const int trial_num) const
{
    Q_ASSERT(trial_num - 1 < m_trials.size());
    if (trial_num <= 0) {
        return false;  // never on the first
    }
    if (m_trials.at(trial_num - 1)->wasCaughtOutReset()) {
        return false;  // never immediately after a reset
    }
    if (trial_num == 1) {
        return true;  // always on the second
    }
    if (m_trials.at(trial_num - 2)->wasCaughtOutReset()) {
        return true;  // always on the second of a fresh run
    }
    return coin(valueDouble(FN_P_CATCH_TRIAL));  // otherwise on e.g. 20% of trials
}


bool CardinalExpDetThreshold::isAuditory() const
{
    return valueInt(FN_MODALITY) == MODALITY_AUDITORY;
}


bool CardinalExpDetThreshold::timeToStop() const
{
    if (m_trial_last_y_b4_first_n < 0) {
        return false;
    }
    const int final_trial_ignoring_catch_trials =
            m_trials[m_trial_last_y_b4_first_n]->trialNumIgnoringCatchTrials()
            + valueInt(FN_NUM_TRIALS_IN_MAIN_SEQUENCE) - 1;
    return m_trials[m_current_trial]->trialNumIgnoringCatchTrials() >=
            final_trial_ignoring_catch_trials;
}


void CardinalExpDetThreshold::clearScene()
{
    m_scene->clear();
}


void CardinalExpDetThreshold::setTimeout(const int time_ms, FuncPtr callback)
{
    m_timer->stop();
    m_timer->disconnect();
    connect(m_timer.data(), &QTimer::timeout,
            this, callback,
            Qt::QueuedConnection);
    m_timer->start(time_ms);
}


void CardinalExpDetThreshold::showVisualStimulus(const QString& filename_stem,
                                                 const qreal intensity)
{
    QString filename = cardinalexpdetcommon::filenameFromStem(filename_stem);
    makeImage(m_scene, VISUAL_STIM_RECT, filename, intensity);
}


void CardinalExpDetThreshold::savingWait()
{
    clearScene();
    makeText(m_scene, SCENE_CENTRE, BASE_TEXT_CONFIG, TextConst::saving());
}


void CardinalExpDetThreshold::reset()
{
    Q_ASSERT(m_current_trial >= 0 && m_current_trial < m_trials.size());
    m_trials.at(m_current_trial)->setCaughtOutReset();
    m_trial_last_y_b4_first_n = -1;
}


void CardinalExpDetThreshold::labelTrialsForAnalysis()
{
    // Trial numbers in the calculation sequence start from 1.
    DbNestableTransaction trans(m_db);
    int tnum = 1;
    for (int i = 0; i < m_trials.size(); ++i) {
        const CardinalExpDetThresholdTrialPtr& t = m_trials.at(i);
        QVariant trial_num_in_seq;  // NULL
        if (i >= m_trial_last_y_b4_first_n && t->targetPresented()) {
            trial_num_in_seq = tnum++;
        }
        t->setTrialNumInCalcSeq(trial_num_in_seq);
    }
}


LogisticDescriptives CardinalExpDetThreshold::calculateFit() const
{
    QVector<double> intensity;  // predictor
    QVector<int> choice;  // dependent variable
    for (const CardinalExpDetThresholdTrialPtr& tp : m_trials) {
        if (tp->isInCalculationSeq()) {
            intensity.append(tp->intensity());
            choice.append(tp->yes() ? 1 : 0);
        }
    }
    qInfo() << "Calculating regression:";
    qInfo() << "Intensities:" << intensity;
    qInfo() << "Choices:" << choice;
    if (intensity.isEmpty()) {
        qWarning() << "No trials found for calculateFit()";
    }
    LogisticDescriptives ld(intensity, choice);  // fit the regression
    return ld;
}


void CardinalExpDetThreshold::calculateAndStoreFit()
{
    const LogisticDescriptives ld = calculateFit();
    qInfo().nospace() << "Coefficients: b0 (intercept) = " << ld.b0()
                      << ", b1 (slope) = " << ld.b1();
    setValue(FN_INTERCEPT, ld.intercept());
    setValue(FN_SLOPE, ld.slope());
    setValue(FN_K, ld.k());
    setValue(FN_THETA, ld.theta());
}


// ============================================================================
// Main task internals
// ============================================================================

void CardinalExpDetThreshold::startTask()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    m_widget->setWidgetAsOnlyContents(m_graphics_widget, 0, false, false);
    onEditStarted();  // will have been stopped by the end of the questionnaire?

    // Finalize the parameters
    const QString TX_DETECTION_Q_VISUAL(tr("Did you see a"));
    const QString TX_DETECTION_Q_AUDITORY(tr("Did you hear a"));
    const bool auditory = isAuditory();
    if (auditory) {
        setValue(FN_BACKGROUND_FILENAME, AUDITORY_BACKGROUND);
        if (valueInt(FN_TARGET_NUMBER) == 0) {
            setValue(FN_TARGET_FILENAME, AUDITORY_TARGETS.at(0));
            setValue(FN_PROMPT, TX_DETECTION_Q_AUDITORY + " " +
                     ExpDetTextConst::auditoryTarget0Short() + "?");
        } else {
            setValue(FN_TARGET_FILENAME, AUDITORY_TARGETS.at(1));
            setValue(FN_PROMPT, TX_DETECTION_Q_AUDITORY + " " +
                     ExpDetTextConst::auditoryTarget1Short() + "?");
        }
    } else {
        setValue(FN_BACKGROUND_FILENAME, VISUAL_BACKGROUND);
        if (valueInt(FN_TARGET_NUMBER) == 0) {
            setValue(FN_TARGET_FILENAME, VISUAL_TARGETS.at(0));
            setValue(FN_PROMPT, TX_DETECTION_Q_VISUAL + " " +
                     ExpDetTextConst::visualTarget0Short() + "?");
        } else {
            setValue(FN_TARGET_FILENAME, VISUAL_TARGETS.at(1));
            setValue(FN_PROMPT, TX_DETECTION_Q_VISUAL + " " +
                     ExpDetTextConst::visualTarget1Short() + "?");
        }
    }

    // Double-check we have a PK before we create trials
    save();

    // Set up players and timers
    soundfunc::makeMediaPlayer(m_player_background);
    soundfunc::makeMediaPlayer(m_player_target);
    if (!m_player_background || !m_player_target) {
        uifunc::alert(TextConst::unableToCreateMediaPlayer());
        return;
    }
    connect(m_player_background.data(), &QMediaPlayer::mediaStatusChanged,
            this, &CardinalExpDetThreshold::mediaStatusChangedBackground);
    timerfunc::makeSingleShotTimer(m_timer);

    // Prep the sounds
    if (auditory) {
        m_player_background->setSource(urlFromStem(
                                valueString(FN_BACKGROUND_FILENAME)));
        soundfunc::setVolume(m_player_background,
                             valueDouble(FN_BACKGROUND_INTENSITY));
        m_player_target->setSource(urlFromStem(
                                valueString(FN_TARGET_FILENAME)));
        // Volume will be set later.
    }

    // Start
    ButtonAndProxy start = makeTextButton(
                m_scene, START_BUTTON_RECT, BASE_BUTTON_CONFIG,
                TextConst::touchToStart());
    CONNECT_BUTTON(start, nextTrial);
}


void CardinalExpDetThreshold::nextTrial()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    clearScene();
    if (timeToStop()) {
        qDebug() << "Time to stop";
        savingWait();
        setValue(FN_FINISHED, true);  // will also be set by thanks() -> finish()
        labelTrialsForAnalysis();
        calculateAndStoreFit();
        save();
        thanks();
    } else {
        startTrial();
    }
}


void CardinalExpDetThreshold::startTrial()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif

    // Increment trial numbers; determine if it's a catch trial (on which no
    // stimulus is presented); create trial record
    ++m_current_trial;
    const bool want_catch = wantCatchTrial(m_current_trial);
    const bool present_target = !want_catch;
    if (!want_catch) {
        ++m_current_trial_ignoring_catch_trials;
    }
    const QVariant trial_ignoring_catch_trials = want_catch
            ? QVariant()  // NULL
            : m_current_trial_ignoring_catch_trials;
    CardinalExpDetThresholdTrialPtr tr(new CardinalExpDetThresholdTrial(
                                           pkvalueInt(),
                                           m_current_trial,  // zero-based trial number
                                           trial_ignoring_catch_trials,
                                           present_target,
                                           m_app,
                                           m_db));
    m_trials.append(tr);
    qDebug() << tr->summary();

    // Display stimulus
    const bool auditory = isAuditory();
    if (present_target) {
        // Now we've put the new trial in the vector, we can calculate intensity:
        const qreal intensity = qBound(0.0, getIntensity(), 1.0);
        // ... intensity is in the range [0, 1]
        tr->setIntensity(intensity);
        if (auditory) {
            soundfunc::setVolume(m_player_target, intensity);
            m_player_background->play();
            m_player_target->play();
        } else {
            showVisualStimulus(valueString(FN_BACKGROUND_FILENAME),
                               valueDouble(FN_BACKGROUND_INTENSITY));
            showVisualStimulus(valueString(FN_TARGET_FILENAME),
                               tr->intensity());
        }
    } else {
        // Catch trial
        if (auditory) {
            m_player_background->play();
        } else {
            showVisualStimulus(valueString(FN_BACKGROUND_FILENAME),
                               valueDouble(FN_BACKGROUND_INTENSITY));
        }
    }

    // If auditory, the event will be driven by the end of the sound, via
    // mediaStatusChangedBackground(). Otherwise:
    if (!auditory) {
        int stimulus_time_ms = msFromSec(
                    valueDouble(FN_VISUAL_TARGET_DURATION_S));
        setTimeout(stimulus_time_ms, &CardinalExpDetThreshold::offerChoice);
    }
}


void CardinalExpDetThreshold::mediaStatusChangedBackground(
        QMediaPlayer::MediaStatus status)
{
    if (status == QMediaPlayer::EndOfMedia) {
#ifdef DEBUG_STEP_DETAIL
        qDebug() << "Background sound playback finished";
#endif
        m_player_target->stop();  // in case it's still playing
        offerChoice();
    }
}


void CardinalExpDetThreshold::offerChoice()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    Q_ASSERT(m_current_trial >= 0 && m_current_trial < m_trials.size());
    CardinalExpDetThresholdTrial& t = *m_trials.at(m_current_trial);
    clearScene();

    makeText(m_scene, PROMPT_CENTRE, BASE_TEXT_CONFIG, valueString(FN_PROMPT));
    ButtonAndProxy y = makeTextButton(m_scene, YES_BUTTON_RECT,
                                      BASE_BUTTON_CONFIG, TextConst::yes());
    ButtonAndProxy n = makeTextButton(m_scene, NO_BUTTON_RECT,
                                      BASE_BUTTON_CONFIG, TextConst::no());
    ButtonAndProxy a = makeTextButton(m_scene, ABORT_BUTTON_RECT,
                                      ABORT_BUTTON_CONFIG, TextConst::abort());
    CONNECT_BUTTON_PARAM(y, recordChoice, true);
    CONNECT_BUTTON_PARAM(n, recordChoice, false);
    CONNECT_BUTTON(a, abort);

    t.recordChoiceTime();
}


void CardinalExpDetThreshold::recordChoice(const bool yes)
{
    Q_ASSERT(m_current_trial >= 0 && m_current_trial < m_trials.size());
    CardinalExpDetThresholdTrial& t = *m_trials.at(m_current_trial);
    t.recordResponse(yes);
    if (!t.targetPresented() && yes) {
        // Caught out... reset.
        reset();
    } else if (m_current_trial == 0 && !yes) {
        // No on first trial -- treat as reset
        reset();
    } else if (t.targetPresented() && !yes && m_trial_last_y_b4_first_n < 0) {
        // First no
        m_trial_last_y_b4_first_n = getNBackNonCatchTrialIndex(1, m_current_trial);
        qDebug() << "First no response: m_trial_last_y_b4_first_n ="
                 << m_trial_last_y_b4_first_n;
    }
    clearScene();
    setTimeout(msFromSec(valueDouble(FN_ITI_S)),
               &CardinalExpDetThreshold::nextTrial);
}


void CardinalExpDetThreshold::thanks()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    clearScene();
    ButtonAndProxy thx = makeTextButton(
                m_scene, THANKS_BUTTON_RECT, BASE_BUTTON_CONFIG,
                TextConst::thankYouTouchToExit());
    CONNECT_BUTTON(thx, finish);
}


void CardinalExpDetThreshold::abort()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    savingWait();
    setValue(FN_FINISHED, false);
    Q_ASSERT(m_widget);
    onEditFinishedAbort();  // will save
    emit m_widget->finished();
}


void CardinalExpDetThreshold::finish()
{
#ifdef DEBUG_STEP_DETAIL
    qDebug() << Q_FUNC_INFO;
#endif
    setValue(FN_FINISHED, true);
    Q_ASSERT(m_widget);
    onEditFinishedProperly();  // will save
    emit m_widget->finished();
}


// ============================================================================
// Translatable text
// ============================================================================

QString CardinalExpDetThreshold::txtAuditory()
{
    return tr("Auditory");
}


QString CardinalExpDetThreshold::txtVisual()
{
    return tr("Visual");
}