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
FileExistsErrorunless you passoverwrite=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,betaRangeanddeltastill override the preset, in both conditions.The settings each staircase used are written to
manifest.jsonunderconfig.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 toHRD/texts/german.yaml, checked against that function in the language snapshot test.timein_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.HeartRateAccepteddistinguishes 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; usemagnitude()andbelieves_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.login the run directory. The tasks narrated themselves withprint, 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 asrecorder=. 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. Needspip install cardioception-toolbox[lsl].A
languageparameter for the Heartbeat Counting task,"english"(the default) or"german", also offered bywrappers/hbc.pyand recorded inmanifest.json. 0.7.2 added it with the texts as Python functions; here they areHBC/texts/english.yamlandgerman.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 indocs/source/tasks/hsd/design.md.The Heartbeat Synchrony Discrimination task runner, in beta:
cardioception.HSD.parameters.getParametersandcardioception.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, andwrappers/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 writesfinal,tones,beats, the posterior per trial and the engine state.setup="behavioral"uses the sharedLSLBeatSourceandTonePlayerincardioception.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, andwrappers/pat_lsl.py. A session writesfinal,tonesandparameters;cardioception.PAT.scoringgives the paper’s 2 x 2 x 2 grid of phase-consistency scores with Monte Carlo chance thresholds. Seedocs/source/tasks/pat.md.The ECG channel is a required field in
wrappers/pat_lsl.pyandwrappers/hsd_lsl.py, with no default: its label is not the same on every hub (ECG1on one,ECG2on 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.getPressedreports the button’s level, not a press, andclickResetresets 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.waitis now a loop that keeps drawing and flipping. Stimulus durations are unchanged; the window simply stays responsive and frame intervals get recorded.An odd
nTrialsno longer fails at setup with anIndexError.The keyboard confidence rating uses
visual.Slider.visual.RatingScalemoved to thepsychopy-legacyplugin 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.schedulenow refuses such a tone (logged, NaN onset), the PAT’sminToneGapmust be at leasttoneDurationplus 0.05 s (it allowed 0), and the HSD’stoneSecsat 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.FloorMeanandPeakMeankeep 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 leavesmuunidentified,muandsigma(soMuMean,MuCILow,MuCIHigh, theSigmacolumns 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 newMuConditionalcolumn says which. Delay placement still uses the marginalmu.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 * nTonesbeats 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).ceilingMarginwidens to 0.10, and a trial with fewer thanminTonestones (3) is judged and written but does not update the posterior; the newUsedandUnusedReasoncolumns 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 BEATSfrom 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 (
AbortReasonno_candidate) instead of crashing the session, and so does a collapsed posterior, which is logged and leaves the trial written withUnusedReasonupdate_failed. The train and the decision loops sleep 1 ms when idle instead of pinning a core. Beats polled after a train are filed inbeats.txtunder that trial, as the marker stream has them, not under the next one.posterior.npyis 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 andTrialStatustimeout; a press more than 1.5 anticipated periods after the last R-wave isstale, with no anticipated period. The PAT scores drop both and report how many inn_dropped.A PAT trial row now records the delay of the last tone actually heard (
LastToneDelayMs, with its anchor, R-wave andLastToneIndex), which differs from the dial for a tone timed from the predicted next beat.tones.txtgains aToneIndexto join on.Esc during a PAT trial pushes an
abortmarker instead of apress, and the aborted trial’s tones are no longer written totones.txtwithout a row infinal.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
dialandpressmarkers 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
languagethat does not exist, used to fail mid-session with aKeyError; both are now errors at launch.The YAML text loader moves from
HRD/languages.pytocardioception._texts, shared by all three tasks. Each task’slanguagesmodule names its own directory and required keys; the HRD’s keeps its signatures.The 64
visual.TextStimconstructions become onetext()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 aTrialOutcomerather 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()andlisten_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.
TaskConfiggathers the design values that were literals insidegetParameters: response window, tutorial lengths, ISI, listening duration, heart-rate cutoffs, staircase bounds, text size. Pass one togetParameters, and it is written tomanifest.jsonwith the data it produced.responseDecisionreturns five values, not six. The second, aresponseTriggertimestamp, was unpacked by its only caller and never read.parametersno longer carrieslambdaInteroorlambdaExtero, which were always empty lists, so a_parameters.picklewill not contain them.The live tests of the beat-locked tone player, which play through the sound card at its own volume, are marked
audioand run only withpytest --run-audio, asblockingtests 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 whenCARDIOCEPTION_RECORDINGSpoints 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 writesevidence.csvandstopping.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 (minTrials15,maxTrials60,ciWidth100 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 ofmuby 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, aConfidenceScalewithConfidenceUnitand the scale columns on every row, the shared rating widgets (the keyboard rating gains awheeloption, and the mouse rating moves into_rating.mouse_rating), texts in YAML checked against a snapshot of the functions they replace, screens and waits throughtext,holdandon_every_frame, validated triggers, and an autopilot that runs whole sessions headless. Its phase arithmetic, scheduling, scoring and data columns are unchanged.
Packaging#
package_datalisted*.wavand*.png, but the stimuli live inImages/andSounds/, so the patterns matched nothing and a wheel built from it shipped no sounds. OnlyMANIFEST.inwas keeping the sdist usable.psychopymoves from==2026.2.2to>=2026.2,<2027. The major-version ceiling stays because 2026 movedvisual.RatingScaleinto a plugin without a deprecation cycle.The documentation workflow deployed to
gh-pagesfrom 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),.Rhistoryand.coverageare no longer tracked. They remain in the history; only new clones of the working tree get smaller.
Removed#
cardioception.reports, withreport,preprocessingand the two notebook templates incardioception/notebooks/thatreportran. 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 inv0.7.2.papermillandpingouinare no longer dependencies, andenvironment_linux.ymlno longer installs PyMC and metadpy.