15.1.786. tablet_qt/tasks/moca.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/>.
*/

#include "moca.h"

#include "common/textconst.h"
#include "lib/datetime.h"
#include "lib/stringfunc.h"
#include "lib/uifunc.h"
#include "maths/mathfunc.h"
#include "questionnairelib/commonoptions.h"
#include "questionnairelib/qucanvas.h"
#include "questionnairelib/qucountdown.h"
#include "questionnairelib/questionnaire.h"
#include "questionnairelib/quimage.h"
#include "questionnairelib/qumcq.h"
#include "questionnairelib/qumcqgrid.h"
#include "questionnairelib/qutext.h"
#include "questionnairelib/qutextedit.h"
#include "tasklib/taskfactory.h"
#include "tasklib/taskregistrar.h"
using mathfunc::noneNull;
using mathfunc::scorePhrase;
using mathfunc::sumInt;
using mathfunc::totalScorePhrase;
using stringfunc::standardResult;
using stringfunc::strnum;
using stringfunc::strseq;

const int FIRST_Q = 1;
const int N_QUESTIONS = 28;
const int MAX_QUESTION_SCORE = 30;
const QString QPREFIX("q");

const QString Moca::MOCA_TABLENAME("moca");

const QString IMAGE_PATH("moca/path.png");
const QString IMAGE_CUBE("moca/cube.png");
const QString IMAGE_CLOCK("moca/clock.png");
const QString IMAGE_ANIMALS("moca/animals.png");
const QString IMAGE_MISSING("moca/missing.png");

const QString EDUCATION12Y_OR_LESS("education12y_or_less");
const QString TRAILPICTURE_BLOBID("trailpicture_blobid");
const QString CUBEPICTURE_BLOBID("cubepicture_blobid");
const QString CLOCKPICTURE_BLOBID("clockpicture_blobid");

const int N_REG_RECALL = 5;
const QString REGISTER_TRIAL1_PREFIX("register_trial1_");
const QString REGISTER_TRIAL2_PREFIX("register_trial2_");
const QString RECALL_CATEGORY_CUE_PREFIX("recall_category_cue_");
const QString RECALL_MC_CUE_PREFIX("recall_mc_cue_");

const QString COMMENTS("comments");

const int NORMAL_IF_GEQ = 26;  // cutoff: normal if score >= this

const QString RECALL_TAG_PREFIX("recall");
const QString SKIP_LABEL("skip");
const QString CATEGORY_RECALL_PAGE_TAG("cr");
const QString MC_RECALL_PAGE_TAG("mc");

void initializeMoca(TaskFactory& factory)
{
    static TaskRegistrar<Moca> registered(factory);
}

Moca::Moca(CamcopsApp& app, DatabaseManager& db, const int load_pk) :
    Task(app, db, MOCA_TABLENAME, false, true, false)  // ... anon, clin, resp
{
    addFields(
        strseq(QPREFIX, FIRST_Q, N_QUESTIONS), QMetaType::fromType<int>()
    );
    addField(EDUCATION12Y_OR_LESS, QMetaType::fromType<int>());
    addField(TRAILPICTURE_BLOBID, QMetaType::fromType<int>());
    // ... FK to BLOB table
    addField(CUBEPICTURE_BLOBID, QMetaType::fromType<int>());
    // ... FK to BLOB table
    addField(CLOCKPICTURE_BLOBID, QMetaType::fromType<int>());
    // ... FK to BLOB table
    addFields(
        strseq(REGISTER_TRIAL1_PREFIX, 1, N_REG_RECALL),
        QMetaType::fromType<int>()
    );
    addFields(
        strseq(REGISTER_TRIAL2_PREFIX, 1, N_REG_RECALL),
        QMetaType::fromType<int>()
    );
    addFields(
        strseq(RECALL_CATEGORY_CUE_PREFIX, 1, N_REG_RECALL),
        QMetaType::fromType<int>()
    );
    addFields(
        strseq(RECALL_MC_CUE_PREFIX, 1, N_REG_RECALL),
        QMetaType::fromType<int>()
    );
    addField(COMMENTS, QMetaType::fromType<QString>());

    load(load_pk);  // MUST ALWAYS CALL from derived Task constructor.
}

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

QString Moca::shortname() const
{
    return "MoCA";
}

QString Moca::longname() const
{
    return tr("Montreal Cognitive Assessment");
}

QString Moca::description() const
{
    return tr("30-point clinician-administered brief cognitive assessment.");
}

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

bool Moca::isComplete() const
{
    return noneNull(values(strseq(QPREFIX, FIRST_Q, N_QUESTIONS)));
}

QStringList Moca::summary() const
{
    return QStringList{totalScorePhrase(totalScore(), MAX_QUESTION_SCORE)};
}

QStringList Moca::detail() const
{
    const int vsp = subScore(1, 5);
    const int naming = subScore(6, 8);
    const int attention = subScore(9, 12);
    const int language = subScore(13, 15);
    const int abstraction = subScore(16, 17);
    const int memory = subScore(18, 22);
    const int orientation = subScore(23, 28);
    const int totalscore = totalScore();
    const QString category = totalscore >= NORMAL_IF_GEQ
        ? TextConst::normal()
        : TextConst::abnormal();
    QStringList reg1;
    QStringList reg2;
    QStringList recallcat;
    QStringList recallmc;
    const QString prefix_registered = xstring("registered");
    const QString prefix_recalled = xstring("recalled");
    const QString suffix_trial = xstring("trial");
    const QString suffix_cat_recall = xstring("category_recall_suffix");
    const QString suffix_mc_recall = xstring("mc_recall_suffix");
    for (int i = 1; i <= N_REG_RECALL; ++i) {
        QString this_q = xstring(strnum("memory_", i));
        reg1 += fieldSummary(
            strnum(REGISTER_TRIAL1_PREFIX, i),
            QString("%1 %2 (%3 1)")
                .arg(prefix_registered, this_q, suffix_trial)
        );
        reg2 += fieldSummary(
            strnum(REGISTER_TRIAL2_PREFIX, i),
            QString("%1 %2 (%3 2)")
                .arg(prefix_registered, this_q, suffix_trial)
        );
        recallcat += fieldSummary(
            strnum(RECALL_CATEGORY_CUE_PREFIX, i),
            QString("%1 %2 %3").arg(prefix_recalled, this_q, suffix_cat_recall)
        );
        recallmc += fieldSummary(
            strnum(RECALL_MC_CUE_PREFIX, i),
            QString("%1 %2 %3").arg(prefix_recalled, this_q, suffix_mc_recall)
        );
    }

    QStringList lines = completenessInfo();
    lines.append(fieldSummary(EDUCATION12Y_OR_LESS, xstring("education_s")));
    lines += fieldSummaries("q", "_s", " ", QPREFIX, 1, 8);
    lines += reg1;
    lines += reg2;
    lines += fieldSummaries("q", "_s", " ", QPREFIX, 9, 22);
    lines += recallcat;
    lines += recallmc;
    lines += fieldSummaries("q", "_s", " ", QPREFIX, 23, N_QUESTIONS);
    lines.append("");
    lines.append(scorePhrase(xstring("subscore_visuospatial"), vsp, 5));
    lines.append(scorePhrase(xstring("subscore_naming"), naming, 3));
    lines.append(scorePhrase(xstring("subscore_attention"), attention, 6));
    lines.append(scorePhrase(xstring("subscore_language"), language, 3));
    lines.append(scorePhrase(xstring("subscore_abstraction"), abstraction, 2));
    lines.append(scorePhrase(xstring("subscore_memory"), memory, 5));
    lines.append(scorePhrase(xstring("subscore_orientation"), orientation, 6));
    lines.append("");
    lines.append(standardResult(xstring("category"), category));
    lines.append("");
    lines += summary();
    return lines;
}

OpenableWidget* Moca::editor(const bool read_only)
{
    QVector<QuPagePtr> pages;
    const bool crippled = !hasExtraStrings();
    const NameValueOptions education_options{
        {xstring("education_option0"), 0},
        {xstring("education_option1"), 1},
    };
    const NameValueOptions options_q12{
        {xstring("q12_option0"), 0},
        {xstring("q12_option1"), 1},
        {xstring("q12_option2"), 2},
        {xstring("q12_option3"), 3},
    };
    const NameValueOptions options_recalled{
        {TextConst::notRecalled(), 0},
        {TextConst::recalled(), 1},
    };
    const NameValueOptions options_corr_incorr
        = CommonOptions::incorrectCorrectInteger();
    const NameValueOptions options_yesno = CommonOptions::noYesInteger();
    const QString correct_date
        = "     " + datetime::nowDate().toString(datetime::LONG_DATE_FORMAT);
    const QString recalled = xstring("recalled");

    auto addpage = [&pages](
                       const QString& title,
                       std::initializer_list<QuElement*> elements,
                       QuPage::PageType type = QuPage::PageType::Inherit,
                       bool allow_scroll = true
                   ) -> void {
        auto p = new QuPage(elements);
        p->setTitle(title);
        p->setType(type);
        if (!allow_scroll) {
            p->allowScroll(false);
        }
        pages.append(QuPagePtr(p));
    };
    auto text = [this](const QString& xstringname) -> QuElement* {
        return new QuText(xstring(xstringname));
    };
    auto boldtext = [this](const QString& xstringname) -> QuElement* {
        return (new QuText(xstring(xstringname)))->setBold();
    };
    auto italic = [](const QString& text) -> QuElement* {
        return (new QuText(text))->setItalic(true);
    };
    auto mcq = [this](
                   const QString& fieldname,
                   const NameValueOptions& options,
                   bool mandatory = true
               ) -> QuElement* {
        return new QuMcq(fieldRef(fieldname, mandatory), options);
    };
    auto grid1 = [this](
                     const QString& fieldname_prefix,
                     const QString& question_prefix,
                     int first,
                     int last,
                     const NameValueOptions& options,
                     bool mandatory = true
                 ) -> QuElement* {
        QVector<QuestionWithOneField> qfields;
        for (int i = first; i <= last; ++i) {
            qfields.append(QuestionWithOneField(
                fieldRef(strnum(fieldname_prefix, i), mandatory),
                xstring(strnum(question_prefix, i))
            ));
        }
        Q_ASSERT(!qfields.empty());
        return new QuMcqGrid(qfields, options);
    };
    auto grid2 = [this](
                     const QString& fieldname_prefix,
                     const QString& first_xstring_name,
                     const QString& xstring_prefix,
                     int first,
                     int last,
                     const NameValueOptions& options,
                     bool mandatory = true
                 ) -> QuElement* {
        QVector<QuestionWithOneField> qfields;
        for (int i = first; i <= last; ++i) {
            qfields.append(QuestionWithOneField(
                fieldRef(strnum(fieldname_prefix, i), mandatory),
                xstring(first_xstring_name) + " "
                    + xstring(strnum(xstring_prefix, i))
            ));
        }
        Q_ASSERT(!qfields.empty());
        return new QuMcqGrid(qfields, options);
    };
    auto viewblob = [this](const QString& blob_id_fieldname) -> QuElement* {
        FieldRefPtr fr = fieldRef(blob_id_fieldname, false, true, true);
        return new QuImage(fr);
    };
    auto canvas
        = [this, &crippled](
              const QString& blob_id_fieldname, const QString& image_filename
          ) -> QuElement* {
        const QString filename = crippled ? IMAGE_MISSING : image_filename;
        QuCanvas* c = new QuCanvas(
            blobFieldRef(blob_id_fieldname, true),
            uifunc::resourceFilename(filename)
        );
        c->setAllowShrink(true);
        return c;
    };
    auto image = [&crippled](const QString& image_filename) -> QuImage* {
        const QString filename = crippled ? IMAGE_MISSING : image_filename;
        return new QuImage(uifunc::resourceFilename(filename));
    };

    addpage(
        xstring("title_preamble"),
        {
            getClinicianQuestionnaireBlockRawPointer(),
            text("education_instructions"),
            mcq(EDUCATION12Y_OR_LESS, education_options),
        }
    );

    addpage(
        xstring("title_prefix_singular") + " 1",
        {
            text("trail_instructions"),
            canvas(TRAILPICTURE_BLOBID, IMAGE_PATH),
        },
        QuPage::PageType::Patient,
        false
    );

    addpage(
        xstring("title_prefix_singular") + " 2",
        {
            text("cube_instructions"),
            canvas(CUBEPICTURE_BLOBID, IMAGE_CUBE),
        },
        QuPage::PageType::Patient,
        false
    );

    addpage(
        xstring("title_prefix_singular") + " 3–5",
        {
            text("clock_instructions"),
            canvas(CLOCKPICTURE_BLOBID, IMAGE_CLOCK),
        },
        QuPage::PageType::Patient,
        false
    );

    addpage(
        xstring("title_prefix_plural") + " 6–8",
        {
            text("naming_instructions"),
            image(IMAGE_ANIMALS),
        },
        QuPage::PageType::ClinicianWithPatient
    );

    addpage(
        xstring("title_prefix_plural") + " 1–8 " + xstring("scoring"),
        {
            viewblob(TRAILPICTURE_BLOBID),
            viewblob(CUBEPICTURE_BLOBID),
            viewblob(CLOCKPICTURE_BLOBID),
            grid1(QPREFIX, "q", 1, 8, options_corr_incorr),
        }
    );

    addpage(
        xstring("title_prefix_plural") + " " + xstring("title_memorize"),
        {
            text("memory_instruction1"),
            grid2(
                REGISTER_TRIAL1_PREFIX,
                "registered",
                "memory_",
                1,
                N_REG_RECALL,
                options_yesno
            ),
            text("memory_instruction2"),
            grid2(
                REGISTER_TRIAL2_PREFIX,
                "registered",
                "memory_",
                1,
                N_REG_RECALL,
                options_yesno
            ),
            text("memory_instruction3"),
        }
    );

    addpage(
        xstring("title_prefix_plural") + " 9–12",
        {
            text("digit_forward_instructions"),
            grid1(QPREFIX, "q", 9, 9, options_corr_incorr),
            text("digit_backward_instructions"),
            grid1(QPREFIX, "q", 10, 10, options_corr_incorr),
            text("tapping_instructions"),
            grid1(QPREFIX, "q", 11, 11, options_corr_incorr),
            text("q12"),
            mcq(strnum(QPREFIX, 12), options_q12),
        }
    );

    addpage(
        xstring("title_prefix_plural") + " 13–15",
        {
            text("repetition_instructions_1"),
            grid1(QPREFIX, "q", 13, 13, options_corr_incorr),
            text("repetition_instructions_2"),
            grid1(QPREFIX, "q", 14, 14, options_corr_incorr),
            text("fluency_instructions"),
            new QuCountdown(60),
            grid1(QPREFIX, "q", 15, 15, options_yesno),
        }
    );

    addpage(
        xstring("title_prefix_plural") + " 16–17",
        {
            text("abstraction_instructions"),
            grid1(QPREFIX, "q", 16, 17, options_corr_incorr),
        }
    );


    QVector<QuestionWithOneField> qf_recall;
    for (int i = 1; i <= N_REG_RECALL; ++i) {
        // Strings range from 1-5 but questions from 18-22.
        int qnum = i + 17;
        QString fieldname = strnum(QPREFIX, qnum);
        qf_recall.append(QuestionWithOneField(
            fieldRef(fieldname), recalled + " " + xstring(strnum("memory_", i))
        ));
        connect(
            fieldRef(fieldname).data(),
            &FieldRef::valueChanged,
            this,
            &Moca::updateMandatory
        );
    }
    addpage(
        xstring("title_prefix_plural") + " 18–22",
        {
            text("recall_instructions"),
            new QuMcqGrid(qf_recall, options_recalled),
        }
    );

    QVector<QuElement*> cat_elements;
    QVector<QuElement*> mc_elements;
    cat_elements.append(text("category_recall_instructions"));
    mc_elements.append(text("mc_recall_instructions"));
    for (int i = 1; i <= N_REG_RECALL; ++i) {
        QString tag = strnum(RECALL_TAG_PREFIX, i);
        cat_elements.append(grid1(
                                RECALL_CATEGORY_CUE_PREFIX,
                                "category_recall_",
                                i,
                                i,
                                options_recalled
        )
                                ->addTag(tag));
        mc_elements.append(
            grid1(RECALL_MC_CUE_PREFIX, "mc_recall_", i, i, options_recalled)
                ->addTag(tag)
        );
        connect(
            fieldRef(strnum(RECALL_CATEGORY_CUE_PREFIX, i)).data(),
            &FieldRef::valueChanged,
            this,
            &Moca::updateMandatory
        );
    }
    cat_elements.append(
        boldtext("no_need_for_extra_recall")->addTag(SKIP_LABEL)
    );
    mc_elements.append(boldtext("no_need_for_extra_recall")->addTag(SKIP_LABEL)
    );

    QuPagePtr cat_recall_page((new QuPage(cat_elements))
                                  ->setTitle(
                                      xstring("title_prefix_plural")
                                      + " 18–22 "
                                      + xstring("category_recall_suffix")
                                  )
                                  ->addTag(CATEGORY_RECALL_PAGE_TAG));
    pages.append(cat_recall_page);

    QuPagePtr mc_recall_page((new QuPage(mc_elements))
                                 ->setTitle(
                                     xstring("title_prefix_plural") + " 18–22 "
                                     + xstring("mc_recall_suffix")
                                 )
                                 ->addTag(MC_RECALL_PAGE_TAG));
    pages.append(mc_recall_page);

    addpage(
        xstring("title_prefix_plural") + " 23–28",
        {
            text("orientation_instructions"),
            grid1(QPREFIX, "q", 23, 28, options_corr_incorr),
            italic(xstring("date_now_is")),
            italic(correct_date),
        }
    );

    addpage(
        TextConst::examinerComments(),
        {
            new QuText(TextConst::examinerCommentsPrompt()),
            (new QuTextEdit(fieldRef(COMMENTS, false)))
                ->setHint(TextConst::examinerComments()),
        }
    );

    m_questionnaire = new Questionnaire(m_app, pages);
    m_questionnaire->setType(QuPage::PageType::Clinician);
    m_questionnaire->setReadOnly(read_only);

    updateMandatory();

    return m_questionnaire;
}

// ============================================================================
// Task-specific calculations
// ============================================================================

int Moca::subScore(const int first, const int last) const
{
    return sumInt(values(strseq(QPREFIX, first, last)));
}

int Moca::totalScore() const
{
    // MOCA instructions:
    // - "The total possible score is 30 points"
    // - "TOTAL SCORE: Sum all subscores listed on the right-hand side. Add one
    //   point for an individual who has 12 years or fewer of formal education,
    //   for a possible maximum of 30 points."
    //
    // - The subscores add up to 30.
    // - So, presumably this means "add one point if you have <=12 years of
    //   education AND your score is less than 30", or equivalently "add one
    //   point... and take the maximum of (your score, 30)".

    int score = subScore(FIRST_Q, N_QUESTIONS);
    if (score < MAX_QUESTION_SCORE) {
        score += valueInt(EDUCATION12Y_OR_LESS);  // extra point for this
    }
    return score;
}

// ============================================================================
// Signal handlers
// ============================================================================

void Moca::updateMandatory()
{
    if (!m_questionnaire) {
        return;
    }
    int n_cat = 0;
    int n_mc = 0;
    for (int i = 1; i <= N_REG_RECALL; ++i) {
        const int qnum = i + 17;
        const QVariant v = value(strnum(QPREFIX, qnum));
        const bool cat_required = v.toInt() == 0;  // also true if NULL
        const QString recall_field = strnum(RECALL_CATEGORY_CUE_PREFIX, i);
        const QString tag = strnum(RECALL_TAG_PREFIX, i);
        fieldRef(recall_field)->setMandatory(cat_required);
        m_questionnaire->setVisibleByTag(
            tag, cat_required, false, CATEGORY_RECALL_PAGE_TAG
        );
        const bool mc_required = cat_required
            && valueInt(strnum(RECALL_CATEGORY_CUE_PREFIX, i)) == 0;
        m_questionnaire->setVisibleByTag(
            tag, mc_required, false, MC_RECALL_PAGE_TAG
        );
        n_cat += cat_required;
        n_mc += mc_required;
    }
    const bool require_cat_skip_label = n_cat == 0;
    const bool require_mc_skip_label = n_mc == 0;
    m_questionnaire->setVisibleByTag(
        SKIP_LABEL, require_cat_skip_label, false, CATEGORY_RECALL_PAGE_TAG
    );
    m_questionnaire->setVisibleByTag(
        SKIP_LABEL, require_mc_skip_label, false, MC_RECALL_PAGE_TAG
    );
}