Changelog#

1.0.0 (unreleased)#

1.0.0 is the first release of the rewritten package. It runs four tasks under one output layout: the Heart Rate Discrimination and Heartbeat Counting tasks, and the Heartbeat Synchrony Discrimination and Phase Adjustment tasks, both in beta. It changes the output schema and the output layout, so analysis scripts written against 0.7.x need the migration notes under Breaking below. The 0.7 line stays installable from the legacy/0.7 branch and the v0.7.2 tag.

Breaking#

Results now live in per-run directories. data/<participant><session>/ becomes data/sub-<participant>/ses-<session>/run-<YYYYMMDD-HHMMSS>/, and every filename carries all three identifiers. Two consequences:

  • Participant and session are no longer concatenated. ("P0", "1001") and ("P01", "001") used to land in the same directory; they no longer can.

  • A repeated session cannot overwrite an earlier one. This has cost real data in longitudinal studies, and the workaround, telling people to append the session to the participant ID, is no longer needed. Starting a session over an existing set of results raises FileExistsError unless you pass overwrite=True.

Read <run directory>/*_final.txt rather than a name you construct yourself. parameters["resultPath"] still exists and now points at the run directory.

listenBPM is the rate over the listening window, 60000 / mean(IBI), where it was previously mean(60000 / IBI). By Jensen’s inequality the second is always the larger; on real PPG the difference is about +0.33 BPM. The tone was therefore reliably faster than the heart it was meant to match. The old value is kept as listenBPM_arithmetic. New sessions are not directly comparable to published ones on this variable.

Confidence is labelled. It previously held 0-100 from the mouse, 1-10 from the HRD keyboard and 1-7 from HBC, with nothing in the output saying which. The raw rating still uses its own scale; ConfidenceUnit carries the same rating on 0-1 so sessions run on different scales can be pooled, and the scale definition is written on every row next to Device.

stairType="updown" raises. The nUp/nDown staircase is gone. It described itself as a 1-down 2-up procedure converging at ~71% accuracy, but built its conditions with nUp=1, nDown=1, which converges at 50%, chance on a two-alternative task. The documentation and the code never agreed, no published Cardioception data used it, and it could not estimate a slope. TrialType no longer contains updown.

Missed trials no longer reach the staircase. A trial with no response was previously scored as Less and fed to addResponse, which pushed the psi posterior on evidence the participant never gave. Missed trials are now excluded and, by default, re-presented later in the session, so session length varies. Set onMissedTrial="skip" for a fixed number of presentations. nRepresentations records how many times each trial was shown.

Added#

  • TaskConfig(psiPreset=...) for the Heart Rate Discrimination task.

    • "legacy", the default, keeps the Psi settings of the original paper.

    • "courtin" uses the per-condition threshold and slope ranges and the 0.01 lapse rate from the hierarchical refit of the Visceral Mind Project data (Courtin et al., 2026).

    • intensRange, alphaRange, betaRange and delta still override the preset, in both conditions.

    • The settings each staircase used are written to manifest.json under config.psi.

  • German instructions for the Heart Rate Discrimination task, language="german", addressing the participant formally. The texts shipped in 0.7.2 as a Python function and move here to HRD/texts/german.yaml, checked against that function in the language snapshot test.

  • time in _signal.txt: absolute epoch seconds for every sample. Without it the PPG recording could not be aligned with the trial triggers or with anything recorded alongside it.

  • manifest.json, written at session start: participant, session, run, seed, version, device, language, staircase, trial count and confidence scale. The parameters pickle is written at the end, so an aborted session used to leave no record of its own settings.

  • A recorded random seed. Every session draws from one generator, and the seed is saved even when the caller did not choose one, so any session can be replayed.

  • Quality columns: HeartRateAttempts, HeartRateAccepted, nRepresentations, DroppedFrames. HeartRateAccepted distinguishes a trial where the task settled on a heart rate from one where it exhausted its attempts and used the last window anyway. This was previously invisible.

  • cardioception.scales.ConfidenceScale, including a signed −100 (certain error) / 0 / +100 (certain correct) VAS. On a signed scale the sign is the believed outcome and the magnitude is the confidence; use magnitude() and believes_correct() to separate them before modelling.

  • Trigger callables at each trial event. These were documented for years but never created, so following the documentation raised KeyError.

  • python -m cardioception.validate <run directory>, which checks a finished session against the invariants the test suite uses.

  • A test suite that runs whole sessions without hardware or a human, and CI that runs it.

  • session.log in the run directory. The tasks narrated themselves with print, which goes to a terminal nobody keeps and carries no timestamps, so a session that failed left nothing to look at. The same messages now go to a timestamped file as well as the console.

  • cardioception.devices.LSLRecorder: ECG or PPG from any Lab Streaming Layer stream, passed as recorder=. ECG beats come from the sleepecg detector, each moved to the R peak, and the task’s trigger codes are also sent to an LSL marker stream, so a LabRecorder file holds the physiology and the events on one clock. Needs pip install cardioception-toolbox[lsl].

  • A language parameter for the Heartbeat Counting task, "english" (the default) or "german", also offered by wrappers/hbc.py and recorded in manifest.json. 0.7.2 added it with the texts as Python functions; here they are HBC/texts/english.yaml and german.yaml, checked against those functions by a snapshot test, and an unknown language fails before a window opens. The German confidence scale reads “Geraten” to “Sicher”.

  • cardioception.HSD.psi.SynchronyPsi, the estimation engine for the Heartbeat Synchrony Discrimination task: an adaptive Bayesian version of the Brener, Liu and Ring method of constant stimuli that chooses each R-wave-to-tone delay by expected information gain and estimates where in the cardiac cycle the heartbeat is felt, and how sharply. Plain NumPy, no PsychoPy or device. The design is documented in docs/source/tasks/hsd/design.md.

  • The Heartbeat Synchrony Discrimination task runner, in beta: cardioception.HSD.parameters.getParameters and cardioception.HSD.task.run, with English instructions, a tutorial with three practice trials, both stimulus axes (ms after the R-wave, or a fraction of the interbeat interval), an adaptive stopping rule, and wrappers/hsd_lsl.py. Each trial’s train is scheduled on the R-waves of an LSL ECG stream and the engine is updated with the delay actually realized. A session writes final, tones, beats, the posterior per trial and the engine state. setup="behavioral" uses the shared LSLBeatSource and TonePlayer in cardioception.devices. Every tone is scheduled a 50 ms latency bias early (TaskConfig.latencyBias), for the transport and speaker delays the recordings cannot see, so no delay below 130 ms is played and the shortest on the default grid is 140 ms. One hardware dry run, 60 trials on a PLUX ECG over Bluetooth, is recorded in the design document, with a simulation rerun at the 130 ms floor. Running the HSD (docs/source/tasks/hsd/index.md) is a how-to for running a session and reading its results.

  • The Phase Adjustment Task, in beta: cardioception.PAT, after the PAT-ECG variant of Hodgetts et al. (2026, Psychophysiology 63:e70415). One tone per heartbeat, timed from the R-waves of an LSL ECG stream, at a delay the participant moves with the mouse wheel until tones and heartbeats feel simultaneous. Relative (phase) and absolute (ms) dial modes, start phases in shuffled blocks, a guided tutorial, an exteroceptive control task on a vibration with a simulated heart, English and Danish instructions, and wrappers/pat_lsl.py. A session writes final, tones and parameters; cardioception.PAT.scoring gives the paper’s 2 x 2 x 2 grid of phase-consistency scores with Monte Carlo chance thresholds. See docs/source/tasks/pat.md.

  • The ECG channel is a required field in wrappers/pat_lsl.py and wrappers/hsd_lsl.py, with no default: its label is not the same on every hub (ECG1 on one, ECG2 on another), so it has to be typed in, and the wrapper does not start without it.

Fixed#

  • A memory leak that reached 2.6 GB over 60 psi trials. The stored posterior was a view onto that trial’s likelihood array, which pinned all of them.

  • Escape now aborts from the Heart Rate Discrimination task’s response and rating screens. It previously did not.

  • A button still held down from the decision no longer submits the confidence rating on the first frame past minRatingTime. Mouse.getPressed reports the button’s level, not a press, and clickReset resets click times rather than state, so a held button was read as a fresh click on the next screen. It was observed in real data as 17 trials with a decision response time of exactly zero.

  • The mouse confidence rating no longer warps the cursor on every frame (issue 103). On macOS each warp suppresses mouse events for 0.25 s, so the slider froze and could be neither moved nor clicked. The cursor is now warped once to the random start and brought back only near the edge of the screen. The fix is in the shared rating, so it applies to every task that uses it.

  • The heart-rate acquisition loop is bounded and can be escaped. A single artefactual interbeat interval could previously hold a participant on the listening screen indefinitely.

  • Every core.wait is now a loop that keeps drawing and flipping. Stimulus durations are unchanged; the window simply stays responsive and frame intervals get recorded.

  • An odd nTrials no longer fails at setup with an IndexError.

  • The keyboard confidence rating uses visual.Slider. visual.RatingScale moved to the psychopy-legacy plugin in PsychoPy 2026 and now raises.

  • A crash or an abort still saves everything the session produced.

  • A missed R peak or a T wave taken for one no longer skews the anticipated period of the live beat source (PAT, HSD) by a third for three beats. An RR interval outside 0.6 to 1.6 times the median of the last five accepted ones is left out of the period and counted in LSLBeatSource.n_rejected.

  • The online R-peak detector restarts its filters after a gap in the ECG timestamps and reports nothing for one period after it, instead of taking the resume transient for a beat. Without beats, its QRS estimate is no longer halved without limit, so electrode-off noise is not taken for beats.

  • Two tones closer than a tone’s duration plus about 30 ms played the second late, because the tone player starts a sound only after the previous one of its kind has ended. TonePlayer.schedule now refuses such a tone (logged, NaN onset), the PAT’s minToneGap must be at least toneDuration plus 0.05 s (it allowed 0), and the HSD’s toneSecs at most 0.2 s.

  • The HSD detection evidence (DetectionEvidence, DetectionBF) no longer rewards biased non-detectors. The floor grid stopped at 0.4 and the peak grid started at 0.5, so a flat responder could be expressed only at yes rates of about 0.3 to 0.6, and anyone else was explained as a rise: in 60 trials, answering always “yes” gave a log10 Bayes factor of +10, always “no” +9 and a flat 0.9 +4 to +7, more than a true detector (+2). The floor and the peak now share one grid of rates from 0.03 to 0.97, so a flat envelope exists at every rate, and the rise is measured inside the range of delays presented, so a bump the task cannot present carries no evidence. The prior on a rise of more than 0.2 is about 0.5, not 0.92. The same responders now give -4.3 to -0.2, the detector +2.2 to +2.7. FloorMean and PeakMean keep their names; the grid is larger (98,820 points) and a stimulus selection takes about 20 ms. Because a flat envelope now keeps posterior mass, and leaves mu unidentified, mu and sigma (so MuMean, MuCILow, MuCIHigh, the Sigma columns and the stopping rule) come from the posterior conditioned on a rise of more than 0.2, falling back to the marginal below 0.01 of mass on a rise; the new MuConditional column says which. Delay placement still uses the marginal mu.

  • An HSD train no longer stops at the first beat whose period puts the tone past the ceiling. That beat is skipped and a later one takes its place, up to 2 * nTones beats a train, so long delays no longer lose most of their tones to respiratory sinus arrhythmia (three dry-run trials got 2 or 3 of 5). ceilingMargin widens to 0.10, and a trial with fewer than minTones tones (3) is judged and written but does not update the posterior; the new Used and UnusedReason columns say which trials did.

  • During an HSD train the screen flips and Esc is read every 50 ms whether or not beats arrive. With a silent beat source the screen used to freeze and Esc wait for the train’s deadline, 10 s or more. A status line is logged every second and says NO BEATS from the first second the source is quiet for 1.5 periods, as the PAT’s does.

  • The HSD beat source is polled during the confidence rating and the breaks. The shared rating and wait drained only the HRD recorder, so HSD beats reached the record up to 7 s late after a rating. They now also call parameters["drain"] when a task sets it; the HRD leaves it unset.

  • Smaller HSD fixes. A period with no candidate between the floor and the ceiling skips the trial (AbortReason no_candidate) instead of crashing the session, and so does a collapsed posterior, which is logged and leaves the trial written with UnusedReason update_failed. The train and the decision loops sleep 1 ms when idle instead of pinning a core. Beats polled after a train are filed in beats.txt under that trial, as the marker stream has them, not under the next one. posterior.npy is float32, half the size (about 10 MB for 60 trials).

  • A PAT trial on a lost ECG no longer waits for Esc or scores as normal. A trial ends after maxTrialTime (60 s) with no choice and TrialStatus timeout; a press more than 1.5 anticipated periods after the last R-wave is stale, with no anticipated period. The PAT scores drop both and report how many in n_dropped.

  • A PAT trial row now records the delay of the last tone actually heard (LastToneDelayMs, with its anchor, R-wave and LastToneIndex), which differs from the dial for a tone timed from the predicted next beat. tones.txt gains a ToneIndex to join on.

  • Esc during a PAT trial pushes an abort marker instead of a press, and the aborted trial’s tones are no longer written to tones.txt without a row in final.txt.

  • In the PAT’s absolute mode, a dial left beyond a period that has since shrunk is clamped to that period before it wraps. A notch from it no longer jumps ahead, and the dial and press markers report the phase that is played.

  • The PAT period outlier rule floors the robust standard deviation at 1 ms. With a fixed period (median absolute deviation 0) float noise used to turn trials into outliers.

  • The PAT’s wait after the press, for the next R-wave (up to 3 s), keeps the window flipping and the frame hook running, and Esc ends it. It used to busy-wait with the screen frozen and Esc ignored.

Internal#

Structural work from the audit’s refactoring report. None of it changes what a participant sees or what a trial records, and each step was checked against the code it replaced rather than read over.

  • Participant-facing text moves from 484 lines of Python to one YAML file per language, validated against a required key set when the session starts. A language missing a key, or a language that does not exist, used to fail mid-session with a KeyError; both are now errors at launch.

  • The YAML text loader moves from HRD/languages.py to cardioception._texts, shared by all three tasks. Each task’s languages module names its own directory and required keys; the HRD’s keeps its signatures.

  • The 64 visual.TextStim constructions become one text() helper, 370 lines to 95.

  • The trigger codes and signal constants are named. Worth knowing: the two tasks write different meanings to the same marker channel. In HRD, 1 is the trial starting and 2 the listening window opening; in HBC, 1 is the listening window opening and 2 its closing.

  • trial() returns a TrialOutcome rather than an 18-element tuple, and that object owns the results row, so column order is decided in one place.

  • The two listening phases become listen_to_heart() and listen_to_tone(). The first is now the only place the task touches physiology.

  • The Heart Rate Discrimination tutorial is a table of phases, instruction screens interleaved with practice blocks, rather than a sequential script. What participants are shown, how many practice trials each block runs and at what difficulty are all set in one place. Verified by tracing all 440 presentation steps across every language, device and condition before and after; they are identical.

  • TaskConfig gathers the design values that were literals inside getParameters: response window, tutorial lengths, ISI, listening duration, heart-rate cutoffs, staircase bounds, text size. Pass one to getParameters, and it is written to manifest.json with the data it produced.

  • responseDecision returns five values, not six. The second, a responseTrigger timestamp, was unpacked by its only caller and never read. parameters no longer carries lambdaIntero or lambdaExtero, which were always empty lists, so a _parameters.pickle will not contain them.

  • The live tests of the beat-locked tone player, which play through the sound card at its own volume, are marked audio and run only with pytest --run-audio, as blocking tests run only with --run-blocking.

  • The online R detector, the beat source and the PAT engine are tested on a committed 60 s ECG excerpt (tests/data/ecg_excerpt.npz) instead of skipping everywhere but the lab PC, and on the full lab recordings too when CARDIOCEPTION_RECORDINGS points at their directory.

  • The HSD simulation (python -m cardioception.HSD.simulate) models the task as it runs: a 130 ms floor (--min-delay, was 80), a heart period per simulated participant that sets the task’s ceiling (--period, --no-ceiling), Gaussian timing noise on the delay heard (--jitter-sd, 26 ms) and trials that deliver no train (--train-loss, 0.07). The observer bank gains four biased non-detectors, and the script writes evidence.csv and stopping.csv. Rerun this way, with 26 and 6 ms of jitter, the design page’s results describe the task as it runs. The HSD stopping defaults (minTrials 15, maxTrials 60, ciWidth 100 ms) are unchanged: the median detector reaches a 100 ms interval only at 75 to 89 trials, but running to 90 would lower the RMSE of mu by about 2 ms.

  • The Phase Adjustment Task, written against 0.7, follows the conventions above: the per-run output directory and manifest, one recorded seed per session, session.log, a ConfidenceScale with ConfidenceUnit and the scale columns on every row, the shared rating widgets (the keyboard rating gains a wheel option, and the mouse rating moves into _rating.mouse_rating), texts in YAML checked against a snapshot of the functions they replace, screens and waits through text, hold and on_every_frame, validated triggers, and an autopilot that runs whole sessions headless. Its phase arithmetic, scheduling, scoring and data columns are unchanged.

Packaging#

  • package_data listed *.wav and *.png, but the stimuli live in Images/ and Sounds/, so the patterns matched nothing and a wheel built from it shipped no sounds. Only MANIFEST.in was keeping the sdist usable.

  • psychopy moves from ==2026.2.2 to >=2026.2,<2027. The major-version ceiling stays because 2026 moved visual.RatingScale into a plugin without a deprecation cycle.

  • The documentation workflow deployed to gh-pages from pull requests as well as from master, so a pull request could replace the live documentation before review. It now builds on pull requests and publishes only from master.

  • .RData (50 MB), .Rhistory and .coverage are no longer tracked. They remain in the history; only new clones of the working tree get smaller.

Removed#

  • cardioception.reports, with report, preprocessing and the two notebook templates in cardioception/notebooks/ that report ran. They read only the file names 0.7 wrote, and mixed quality control with outdated analysis choices. Reporting will be rebuilt from scratch; until then, the inspecting-data tutorial covers the same checks. The 0.7 reports remain in v0.7.2.

  • papermill and pingouin are no longer dependencies, and environment_linux.yml no longer installs PyMC and metadpy.