Coverage for tasks/cardinal_expdetthreshold.py: 33%

1"""

2 camcops_server/tasks/cardinal_expdetthreshold.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

11 CamCOPS is free software: you can redistribute it and/or modify

12 it under the terms of the GNU General Public License as published by

13 the Free Software Foundation, either version 3 of the License, or

14 (at your option) any later version.

15

16 CamCOPS is distributed in the hope that it will be useful,

17 but WITHOUT ANY WARRANTY; without even the implied warranty of

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

21 You should have received a copy of the GNU General Public License

22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

23

24===============================================================================

25

26"""

27

28 import math

29 import logging

30 from typing import List, Optional, Tuple, Type

31

32 from cardinal_pythonlib.maths_numpy import inv_logistic, logistic

33 import cardinal_pythonlib.rnc_web as ws

34 from matplotlib.figure import Figure

35 import numpy as np

36 from pendulum import DateTime as Pendulum

37 from sqlalchemy.orm import Mapped, mapped_column

38 from sqlalchemy.sql.sqltypes import Text, UnicodeText

39

40 from camcops_server.cc_modules.cc_constants import (

41 CssClass,

42 MatplotlibConstants,

43 PlotDefaults,

44)

45 from camcops_server.cc_modules.cc_db import (

46 ancillary_relationship,

47 GenericTabletRecordMixin,

48 TaskDescendant,

49)

50 from camcops_server.cc_modules.cc_html import get_yes_no_none, tr_qa

51 from camcops_server.cc_modules.cc_request import CamcopsRequest

52 from camcops_server.cc_modules.cc_sqla_coltypes import (

53 mapped_camcops_column,

54 PendulumDateTimeAsIsoTextColType,

55)

56 from camcops_server.cc_modules.cc_sqlalchemy import Base

57 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

58 from camcops_server.cc_modules.cc_text import SS

59

60 log = logging.getLogger(__name__)

61

62

63 LOWER_MARKER = 0.25

64 UPPER_MARKER = 0.75

65 EQUATION_COMMENT = (

66 "logits: L(X) = intercept + slope * X; "

67 "probability: P = 1 / (1 + exp(-intercept - slope * X))"

68)

69 MODALITY_AUDITORY = 0

70 MODALITY_VISUAL = 1

71 DP = 3

72

73

74# =============================================================================

75# CardinalExpDetThreshold

76# =============================================================================

77

78

79 class CardinalExpDetThresholdTrial(

80 GenericTabletRecordMixin, TaskDescendant, Base

81):

82 __tablename__ = "cardinal_expdetthreshold_trials"

83

84 cardinal_expdetthreshold_id: Mapped[int] = mapped_column(

85 comment="FK to CardinalExpDetThreshold",

86 )

87 trial: Mapped[int] = mapped_column(comment="Trial number (0-based)")

88

89 # Results

90 trial_ignoring_catch_trials: Mapped[Optional[int]] = mapped_column(

91 comment="Trial number, ignoring catch trials (0-based)",

92 )

93 target_presented: Mapped[Optional[int]] = mapped_column(

94 comment="Target presented? (0 no, 1 yes)"

95 )

96 target_time: Mapped[Optional[Pendulum]] = mapped_column(

97 PendulumDateTimeAsIsoTextColType,

98 comment="Target presentation time (ISO-8601)",

99 )

100 intensity: Mapped[Optional[float]] = mapped_column(

101 comment="Target intensity (0.0-1.0)"

102 )

103 choice_time: Mapped[Optional[Pendulum]] = mapped_column(

104 PendulumDateTimeAsIsoTextColType,

105 comment="Time choice offered (ISO-8601)",

106 )

107 responded: Mapped[Optional[int]] = mapped_column(

108 comment="Responded? (0 no, 1 yes)"

109 )

110 response_time: Mapped[Optional[Pendulum]] = mapped_column(

111 PendulumDateTimeAsIsoTextColType,

112 comment="Time of response (ISO-8601)",

113 )

114 response_latency_ms: Mapped[Optional[int]] = mapped_column(

115 comment="Response latency (ms)"

116 )

117 yes: Mapped[Optional[int]] = mapped_column(

118 comment="Subject chose YES? (0 didn't, 1 did)"

119 )

120 no: Mapped[Optional[int]] = mapped_column(

121 comment="Subject chose NO? (0 didn't, 1 did)"

122 )

123 caught_out_reset: Mapped[Optional[int]] = mapped_column(

124 comment="Caught out on catch trial, thus reset? (0 no, 1 yes)",

125 )

126 trial_num_in_calculation_sequence: Mapped[Optional[int]] = mapped_column(

127 comment="Trial number as used for threshold calculation",

128 )

129

130 @classmethod

131 def get_html_table_header(cls) -> str:

132 return f"""

133 <table class="{CssClass.EXTRADETAIL}">

134 <tr>

135 <th>Trial# (0-based)</th>

136 <th>Trial# (ignoring catch trials) (0-based)</th>

137 <th>Target presented?</th>

138 <th>Target time</th>

139 <th>Intensity</th>

140 <th>Choice time</th>

141 <th>Responded?</th>

142 <th>Response time</th>

143 <th>Response latency (ms)</th>

144 <th>Yes?</th>

145 <th>No?</th>

146 <th>Caught out (and reset)?</th>

147 <th>Trial# in calculation sequence</th>

148 </tr>

149 """

150

151 def get_html_table_row(self) -> str:

152 return ("<tr>" + "<td>{}</td>" * 13 + "</th>").format(

153 self.trial,

154 self.trial_ignoring_catch_trials,

155 self.target_presented,

156 self.target_time,

157 ws.number_to_dp(self.intensity, DP),

158 self.choice_time,

159 self.responded,

160 self.response_time,

161 self.response_latency_ms,

162 self.yes,

163 self.no,

164 ws.webify(self.caught_out_reset),

165 ws.webify(self.trial_num_in_calculation_sequence),

166 )

167

168 # -------------------------------------------------------------------------

169 # TaskDescendant overrides

170 # -------------------------------------------------------------------------

171

172 @classmethod

173 def task_ancestor_class(cls) -> Optional[Type["Task"]]:

174 return CardinalExpDetThreshold

175

176 def task_ancestor(self) -> Optional["CardinalExpDetThreshold"]:

177 return CardinalExpDetThreshold.get_linked( # type: ignore[return-value] # noqa: E501

178 self.cardinal_expdetthreshold_id, self

179 )

180

181

182 class CardinalExpDetThreshold(TaskHasPatientMixin, Task): # type: ignore[misc]

183 """

184 Server implementation of the Cardinal_ExpDetThreshold task.

185 """

186

187 __tablename__ = "cardinal_expdetthreshold"

188 shortname = "Cardinal_ExpDetThreshold"

189 use_landscape_for_pdf = True

190

191 # Config

192 modality: Mapped[Optional[int]] = mapped_column(

193 comment="Modality (0 auditory, 1 visual)"

194 )

195 target_number: Mapped[Optional[int]] = mapped_column(

196 comment="Target number (within available targets of that modality)",

197 )

198 background_filename: Mapped[Optional[str]] = mapped_camcops_column(

199 Text,

200 exempt_from_anonymisation=True,

201 comment="Filename of media used for background",

202 )

203 target_filename: Mapped[Optional[str]] = mapped_camcops_column(

204 "target_filename",

205 Text,

206 exempt_from_anonymisation=True,

207 comment="Filename of media used for target",

208 )

209 visual_target_duration_s: Mapped[Optional[float]] = mapped_column(

210 comment="Visual target duration (s)"

211 )

212 background_intensity: Mapped[Optional[float]] = mapped_column(

213 comment="Intensity of background (0.0-1.0)",

214 )

215 start_intensity_min: Mapped[Optional[float]] = mapped_column(

216 comment="Minimum starting intensity (0.0-1.0)",

217 )

218 start_intensity_max: Mapped[Optional[float]] = mapped_column(

219 comment="Maximum starting intensity (0.0-1.0)",

220 )

221 initial_large_intensity_step: Mapped[Optional[float]] = mapped_column(

222 comment="Initial, large, intensity step (0.0-1.0)",

223 )

224 main_small_intensity_step: Mapped[Optional[float]] = mapped_column(

225 comment="Main, small, intensity step (0.0-1.0)",

226 )

227 num_trials_in_main_sequence: Mapped[Optional[int]] = mapped_column(

228 comment="Number of trials required in main sequence",

229 )

230 p_catch_trial: Mapped[Optional[float]] = mapped_column(

231 comment="Probability of catch trial"

232 )

233 prompt: Mapped[Optional[str]] = mapped_camcops_column(

234 UnicodeText,

235 exempt_from_anonymisation=True,

236 comment="Prompt given to subject",

237 )

238 iti_s: Mapped[Optional[float]] = mapped_column(

239 comment="Intertrial interval (s)"

240 )

241

242 # Results

243 finished: Mapped[Optional[int]] = mapped_column(

244 comment="Subject finished successfully (0 no, 1 yes)",

245 )

246 intercept: Mapped[Optional[float]] = mapped_column(

247 comment=EQUATION_COMMENT

248 )

249 slope: Mapped[Optional[float]] = mapped_column(comment=EQUATION_COMMENT)

250 k: Mapped[Optional[float]] = mapped_column(

251 comment=EQUATION_COMMENT + "; k = slope"

252 )

253 theta: Mapped[Optional[float]] = mapped_column(

254 comment=EQUATION_COMMENT + "; theta = -intercept/k = -intercept/slope",

255 )

256

257 # Relationships

258 trials = ancillary_relationship( # type: ignore[assignment]

259 parent_class_name="CardinalExpDetThreshold",

260 ancillary_class_name="CardinalExpDetThresholdTrial",

261 ancillary_fk_to_parent_attr_name="cardinal_expdetthreshold_id",

262 ancillary_order_by_attr_name="trial",

263 ) # type: List[CardinalExpDetThresholdTrial]

264

265 @staticmethod

266 def longname(req: "CamcopsRequest") -> str:

267 _ = req.gettext

268 return _(

269 "Cardinal RN – Threshold determination for "

270 "Expectation–Detection task"

271 )

272

273 def is_complete(self) -> bool:

274 return bool(self.finished)

275

276 def _get_figures(

277 self, req: CamcopsRequest

278 ) -> Tuple[Figure, Optional[Figure]]:

279 """

280 Create and return figures. Returns ``trialfig, fitfig``.

281 """

282 trialarray = self.trials

283

284 # Constants

285 jitter_step = 0.02

286 dp_to_consider_same_for_jitter = 3

287 y_extra_space = 0.1

288 x_extra_space = 0.02

289 figsize = (

290 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 2,

291 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 2,

292 )

293

294 # Figure and axes

295 trialfig = req.create_figure(figsize=figsize)

296 trialax = trialfig.add_subplot(MatplotlibConstants.WHOLE_PANEL)

297 fitfig = None # type: Optional[Figure]

298

299 # Anything to do?

300 if not trialarray:

301 return trialfig, fitfig

302

303 # Data

304 notcalc_detected_x = []

305 notcalc_detected_y = []

306 notcalc_missed_x = []

307 notcalc_missed_y = []

308 calc_detected_x = []

309 calc_detected_y = []

310 calc_missed_x = []

311 calc_missed_y = []

312 catch_detected_x = []

313 catch_detected_y = []

314 catch_missed_x = []

315 catch_missed_y = []

316 all_x = []

317 all_y = []

318 for t in trialarray:

319 x = t.trial

320 y = t.intensity

321 all_x.append(x)

322 all_y.append(y)

323 if t.trial_num_in_calculation_sequence is not None:

324 if t.yes:

325 calc_detected_x.append(x)

326 calc_detected_y.append(y)

327 else:

328 calc_missed_x.append(x)

329 calc_missed_y.append(y)

330 elif t.target_presented:

331 if t.yes:

332 notcalc_detected_x.append(x)

333 notcalc_detected_y.append(y)

334 else:

335 notcalc_missed_x.append(x)

336 notcalc_missed_y.append(y)

337 else: # catch trial

338 if t.yes:

339 catch_detected_x.append(x)

340 catch_detected_y.append(y)

341 else:

342 catch_missed_x.append(x)

343 catch_missed_y.append(y)

344

345 # Create trialfig plots

346 trialax.plot(

347 all_x,

348 all_y,

349 marker=MatplotlibConstants.MARKER_NONE,

350 color=MatplotlibConstants.COLOUR_GREY_50,

351 linestyle=MatplotlibConstants.LINESTYLE_SOLID,

352 label=None,

353 )

354 trialax.plot(

355 notcalc_missed_x,

356 notcalc_missed_y,

357 marker=MatplotlibConstants.MARKER_CIRCLE,

358 color=MatplotlibConstants.COLOUR_BLACK,

359 linestyle=MatplotlibConstants.LINESTYLE_NONE,

360 label="miss",

361 )

362 trialax.plot(

363 notcalc_detected_x,

364 notcalc_detected_y,

365 marker=MatplotlibConstants.MARKER_PLUS,

366 color=MatplotlibConstants.COLOUR_BLACK,

367 linestyle=MatplotlibConstants.LINESTYLE_NONE,

368 label="hit",

369 )

370 trialax.plot(

371 calc_missed_x,

372 calc_missed_y,

373 marker=MatplotlibConstants.MARKER_CIRCLE,

374 color=MatplotlibConstants.COLOUR_RED,

375 linestyle=MatplotlibConstants.LINESTYLE_NONE,

376 label="miss, scored",

377 )

378 trialax.plot(

379 calc_detected_x,

380 calc_detected_y,

381 marker=MatplotlibConstants.MARKER_PLUS,

382 color=MatplotlibConstants.COLOUR_BLUE,

383 linestyle=MatplotlibConstants.LINESTYLE_NONE,

384 label="hit, scored",

385 )

386 trialax.plot(

387 catch_missed_x,

388 catch_missed_y,

389 marker=MatplotlibConstants.MARKER_CIRCLE,

390 color=MatplotlibConstants.COLOUR_GREEN,

391 linestyle=MatplotlibConstants.LINESTYLE_NONE,

392 label="CR",

393 )

394 trialax.plot(

395 catch_detected_x,

396 catch_detected_y,

397 marker=MatplotlibConstants.MARKER_STAR,

398 color=MatplotlibConstants.COLOUR_GREEN,

399 linestyle=MatplotlibConstants.LINESTYLE_NONE,

400 label="FA",

401 )

402 leg = trialax.legend(

403 numpoints=1,

404 fancybox=True, # for set_alpha (below)

405 loc="best", # bbox_to_anchor=(0.75, 1.05)

406 labelspacing=0,

407 handletextpad=0,

408 prop=req.fontprops,

409 )

410 leg.get_frame().set_alpha(0.5)

411 trialax.set_xlabel("Trial number (0-based)", fontdict=req.fontdict)

412 trialax.set_ylabel("Intensity", fontdict=req.fontdict)

413 trialax.set_ylim(0 - y_extra_space, 1 + y_extra_space)

414 trialax.set_xlim(-0.5, len(trialarray) - 0.5)

415 req.set_figure_font_sizes(trialax)

416

417 # Anything to do for fitfig?

418 if self.k is None or self.theta is None:

419 return trialfig, fitfig

420

421 # Create fitfig

422 fitfig = req.create_figure(figsize=figsize)

423 fitax = fitfig.add_subplot(MatplotlibConstants.WHOLE_PANEL)

424 detected_x = []

425 detected_x_approx = [] # type: ignore[var-annotated]

426 detected_y = []

427 missed_x = []

428 missed_x_approx = [] # type: ignore[var-annotated]

429 missed_y = []

430 all_x = []

431 for t in trialarray:

432 if t.trial_num_in_calculation_sequence is not None:

433 all_x.append(t.intensity)

434 approx_x = f"{t.intensity:.{dp_to_consider_same_for_jitter}f}"

435 if t.yes:

436 detected_y.append(

437 1 - detected_x_approx.count(approx_x) * jitter_step

438 )

439 detected_x.append(t.intensity)

440 detected_x_approx.append(approx_x)

441 else:

442 missed_y.append(

443 0 + missed_x_approx.count(approx_x) * jitter_step

444 )

445 missed_x.append(t.intensity)

446 missed_x_approx.append(approx_x)

447

448 # Again, anything to do for fitfig?

449 if not all_x:

450 return trialfig, fitfig

451

452 fit_x = np.arange(0.0 - x_extra_space, 1.0 + x_extra_space, 0.001)

453 fit_y = logistic(fit_x, self.k, self.theta)

454 fitax.plot(

455 fit_x,

456 fit_y,

457 color=MatplotlibConstants.COLOUR_GREEN,

458 linestyle=MatplotlibConstants.LINESTYLE_SOLID,

459 )

460 fitax.plot(

461 missed_x,

462 missed_y,

463 marker=MatplotlibConstants.MARKER_CIRCLE,

464 color=MatplotlibConstants.COLOUR_RED,

465 linestyle=MatplotlibConstants.LINESTYLE_NONE,

466 )

467 fitax.plot(

468 detected_x,

469 detected_y,

470 marker=MatplotlibConstants.MARKER_PLUS,

471 color=MatplotlibConstants.COLOUR_BLUE,

472 linestyle=MatplotlibConstants.LINESTYLE_NONE,

473 )

474 fitax.set_ylim(0 - y_extra_space, 1 + y_extra_space)

475 fitax.set_xlim(

476 np.amin(all_x) - x_extra_space, np.amax(all_x) + x_extra_space

477 )

478 marker_points = []

479 for y in (LOWER_MARKER, 0.5, UPPER_MARKER):

480 x = inv_logistic(y, self.k, self.theta) # type: ignore[assignment]

481 marker_points.append((x, y))

482 for p in marker_points:

483 fitax.plot(

484 [p[0], p[0]], # x

485 [-1, p[1]], # y

486 color=MatplotlibConstants.COLOUR_GREY_50,

487 linestyle=MatplotlibConstants.LINESTYLE_DOTTED,

488 )

489 fitax.plot(

490 [-1, p[0]], # x

491 [p[1], p[1]], # y

492 color=MatplotlibConstants.COLOUR_GREY_50,

493 linestyle=MatplotlibConstants.LINESTYLE_DOTTED,

494 )

495 fitax.set_xlabel("Intensity", fontdict=req.fontdict)

496 fitax.set_ylabel(

497 "Detected? (0=no, 1=yes; jittered)", fontdict=req.fontdict

498 )

499 req.set_figure_font_sizes(fitax)

500

501 # Done

502 return trialfig, fitfig

503

504 def get_trial_html(self, req: CamcopsRequest) -> str:

505 """

506 Note re plotting markers without lines:

507

508 .. code-block:: python

509

510 import matplotlib.pyplot as plt

511

512 fig, ax = plt.subplots()

513 ax.plot([1, 2], [1, 2], marker="+", color="r", linestyle="-")

514 ax.plot([1, 2], [2, 1], marker="o", color="b", linestyle="None")

515 fig.savefig("test.png")

516 # ... the "absent" line does NOT "cut" the red one.

517

518 Args:

519 req:

520

521 Returns:

522

523 """

524 trialarray = self.trials

525 html = CardinalExpDetThresholdTrial.get_html_table_header()

526 for t in trialarray:

527 html += t.get_html_table_row()

528 html += """</table>"""

529

530 # Don't add figures if we're incomplete

531 if not self.is_complete():

532 return html

533

534 # Add figures

535 trialfig, fitfig = self._get_figures(req)

536

537 html += f"""

538 <table class="{CssClass.NOBORDER}">

539 <tr>

540 <td class="{CssClass.NOBORDERPHOTO}">

541 {req.get_html_from_pyplot_figure(trialfig)}

542 </td>

543 <td class="{CssClass.NOBORDERPHOTO}">

544 {req.get_html_from_pyplot_figure(fitfig)}

545 </td>

546 </tr>

547 </table>

548 """

549

550 return html

551

552 def logistic_x_from_p(self, p: Optional[float]) -> Optional[float]:

553 try:

554 return (math.log(p / (1 - p)) - self.intercept) / self.slope

555 except (TypeError, ValueError):

556 return None

557

558 def get_task_html(self, req: CamcopsRequest) -> str:

559 if self.modality == MODALITY_AUDITORY:

560 modality = req.sstring(SS.AUDITORY)

561 elif self.modality == MODALITY_VISUAL:

562 modality = req.sstring(SS.VISUAL)

563 else:

564 modality = None

565 h = f"""

566 <div class="{CssClass.SUMMARY}">

567 <table class="{CssClass.SUMMARY}">

568 {self.get_is_complete_tr(req)}

569 </table>

570 </div>

571 <div class="{CssClass.EXPLANATION}">

572 The ExpDet-Threshold task measures visual and auditory

573 thresholds for stimuli on a noisy background, using a

574 single-interval up/down method. It is intended as a prequel to

575 the Expectation–Detection task.

576 </div>

577 <table class="{CssClass.TASKCONFIG}">

578 <tr>

579 <th width="50%">Configuration variable</th>

580 <th width="50%">Value</th>

581 </tr>

582 """

583 h += tr_qa("Modality", modality)

584 h += tr_qa("Target number", self.target_number)

585 h += tr_qa("Background filename", ws.webify(self.background_filename))

586 h += tr_qa("Background intensity", self.background_intensity)

587 h += tr_qa("Target filename", ws.webify(self.target_filename))

588 h += tr_qa(

589 "(For visual targets) Target duration (s)",

590 self.visual_target_duration_s,

591 )

592 h += tr_qa("Start intensity (minimum)", self.start_intensity_min)

593 h += tr_qa("Start intensity (maximum)", self.start_intensity_max)

594 h += tr_qa(

595 "Initial (large) intensity step", self.initial_large_intensity_step

596 )

597 h += tr_qa(

598 "Main (small) intensity step", self.main_small_intensity_step

599 )

600 h += tr_qa(

601 "Number of trials in main sequence",

602 self.num_trials_in_main_sequence,

603 )

604 h += tr_qa("Probability of a catch trial", self.p_catch_trial)

605 h += tr_qa("Prompt", self.prompt)

606 h += tr_qa("Intertrial interval (ITI) (s)", self.iti_s)

607 h += f"""

608 </table>

609 <table class="{CssClass.TASKDETAIL}">

610 <tr><th width="50%">Measure</th><th width="50%">Value</th></tr>

611 """

612 h += tr_qa("Finished?", get_yes_no_none(req, self.finished))

613 h += tr_qa("Logistic intercept", ws.number_to_dp(self.intercept, DP))

614 h += tr_qa("Logistic slope", ws.number_to_dp(self.slope, DP))

615 h += tr_qa("Logistic k (= slope)", ws.number_to_dp(self.k, DP))

616 h += tr_qa(

617 "Logistic theta (= –intercept/slope)",

618 ws.number_to_dp(self.theta, DP),

619 )

620 h += tr_qa(

621 f"Intensity for {100 * LOWER_MARKER}% detection",

622 ws.number_to_dp(self.logistic_x_from_p(LOWER_MARKER), DP),

623 )

624 h += tr_qa(

625 "Intensity for 50% detection", ws.number_to_dp(self.theta, DP)

626 )

627 h += tr_qa(

628 f"Intensity for {100 * UPPER_MARKER}% detection",

629 ws.number_to_dp(self.logistic_x_from_p(UPPER_MARKER), DP),

630 )

631 h += """

632 </table>

633 """

634 h += self.get_trial_html(req)

635 return h