← Back to the no_peak app

Methods & Algorithm Reference

Detecting hormone pulses with CLUSTER: algorithm, implementations, and measured performance

What the algorithm behind no_peak does, which two implementations of it ship, how each was checked against its reference, and how well it actually recovers pulses that are known to be there.

The problem

Many hormones are secreted in bursts rather than continuously. Sample an animal's blood every few minutes and you get a jagged concentration series; the scientific question is which of those excursions are genuine secretory events and which are assay noise. Get that wrong and every downstream number — pulse frequency, amplitude, inter-pulse interval — is wrong with it.

CLUSTER is the algorithm Johannes D. Veldhuis and Michael L. Johnson published in 1986 to answer that question, and it remains in routine use forty years later. no_peak is a reimplementation of it that runs in a web browser.

How CLUSTER works

CLUSTER asks one local question, repeatedly. At every sample it compares the mean of the next npeak points against the mean of the preceding nnadir points using a pooled t-test, scaled by the measurement error at each point. When that t-statistic exceeds the user's threshold, the sample is flagged as a significant increase (an up) or, scanning for the opposite sign, a significant decrease (a down). A second pass assembles those flags into pulse regions, and a third tabulates the resulting peaks and valleys.

What makes the approach durable is what it does not assume. There is no pulse shape, no secretion-and-clearance kinetic model, no baseline function, and no assumption that pulses are regularly spaced. The only model is the measurement error: because the test is scaled by each point's error, a large jump in a noisy stretch of the record may not register, while a modest, well-measured rise can. That is deliberate — it keeps the detector honest about assay precision rather than about biology.

The error model

The per-point error can be estimated from the spread of the data values themselves — over a sliding window, or across the whole record, as an SD or a standard error; from a square-root model for count-like data; from a fixed value; or from a user-supplied error column, which is what you want when your assay reports its own per-sample precision.

The parameters are the model

The price of CLUSTER's simplicity is that the detection parameters — the two window widths, the two t-score thresholds, and the minimum value a pulse must reach — are the model. Different settings give different answers, so publications using CLUSTER should report all five along with the error model, and it is worth checking that conclusions survive a sweep of nearby values.

CLUSTER also identifies when pulses occur. It does not estimate secretion rates, hormone half-life, or pulse mass; those are the province of deconvolution methods, which are listed at the end of this page.

Which implementation you are running

Three implementations sit behind this app, and the differences between them are documented rather than smoothed over:

The Igor port and the original Fortran genuinely disagree in places, so no_peak's Implementation selector switches between them rather than picking a winner. The pooled variance term differs (Igor sums the standard deviations, the Fortran squares them first), and the pulse-assembly pass marks a different number of points per up-flag. Selecting Fortran mode also switches the interface to the green-phosphor terminal look the program originally ran under.

One deliberate departure from both: when a pulse is still in progress at the end of the record, the original code silently dropped it, because the statistics that need a following baseline cannot be computed. no_peak counts that pulse by default and marks the affected statistics as unavailable, since a detected pulse that vanishes from the table is more confusing than one with missing columns. The original behavior is one checkbox away.

Two discrepancies found by validating rather than reading

Both were invisible to careful source reading and only appeared when the reference programs were actually run:

Validation

Three independent checks, in increasing order of what they actually prove:

1. Against Igor Pro

Fifteen runs spanning every error model, asymmetric window sizes, threshold combinations and three datasets were executed in Igor and diffed point by point — error array, up flags, down flags, pulse array, t-score trace. 75 of 75 checks pass.

2. Against the original Fortran

CLUST5 v6.01 was compiled with gfortran and run on the same input. At the documented defaults the port reproduces it exactly: all 96 up flags, all 96 down flags, the full 96-point pulse array, and all 17 peaks including position, width, height, largest and mean percent increase, area, and increase above basal.

3. Against known ground truth

The first check that asks "does it find the pulses that are really there?" rather than "does it match the reference implementations?". Johnson's Pulse_XP distribution ships simulated series together with their generating pulse times. Across 130 true pulses in six datasets, at default settings:

DetectorSensitivityFalse positives
no_peak, fortran variant60.8%0
Johnson's published Cluster figure~58%
PULSAR Otago, best of a threshold sweep56.2%6
no_peak, igor variant51.5%0
AutoDecon, published, same data~98% ⚠ (a detection count, not a scored sensitivity — see docs/validation-status.md)

The two CLUSTER rows were last re-derived 2026-08-12 and reproduce exactly, per-dataset as well as in total. The PULSAR row has not been re-derived: the committed pulsar_run.R runs one file and prints peak times, while the dataset loop, threshold sweep and scorer behind that number are not in the repository. Johnson's datasets cannot be redistributed either, so unlike the rest of this page these numbers cannot be re-run from a clone alone.

Read that as a description, not a scoreboard. PULSAR Otago reports a peak time while CLUSTER reports a pulse span, so PULSAR was credited a hit within ±2 sampling intervals of a true onset while CLUSTER had to contain the onset within its span ±1 — a matching rule generous to PULSAR, which still produced the only false positives in the comparison. Against that, PULSAR's parameters here are our estimates: its paper derives values for mouse LH, not for Johnson's simulated series, so a specialist could very likely do better.

The honest summary is that all three CLUSTER-family detectors land in the 50–60% band, distinguished by never producing a false positive, while deconvolution is in a different class entirely. If you need to count pulses, use deconvolution. If you need to defend every pulse you report, the positive accuracy is the argument.

A caveat on CLUSTER's false-positive rate

CLUSTER's celebrated near-zero false-positive rate is partly a property of the benchmark it is usually measured on.

Scored on Johnson's simulated datasets, this port produces zero false positives. Scored on a broad simulated corpus spanning realistic ranges of sampling interval, half-life, pulse mass and inter-pulse interval, the same code produces a 16–22% false-discovery rate. Both numbers are correct.

The difference is pulse density. Johnson's datasets carry 17–30 pulses in 145 samples — roughly one per five points, so almost any detection lands near a true pulse and can hardly be counted wrong. A broad corpus averages 6.5 pulses per record. Tested directly, a density-matched corpus reproduces the published behavior (55.8% sensitivity for the Igor variant and 58.4% for the Fortran, both under a 0.5% false-discovery rate, against Johnson's published ~58%), which localizes the discrepancy to the benchmark rather than to the port.

So the "≈1% false positives" CLUSTER is credited with in the AutoDecon comparison should be read as conditional on dense pulse trains. For sparse records — short sampling windows, or genuinely infrequent pulses — expect a materially higher false-positive rate and set thresholds accordingly. Both variants reproduce their references exactly; this is a property of the algorithm that the standard benchmarks do not expose.

A related calibration point, measured against the residuals of Johnson's own files: assays report standard deviations about 10–20% larger than the actual noise. Because CLUSTER's t-test is calibrated on the reported error, that conservatism is precisely what holds its false-positive rate down.

Using the tool

Input is plain text, one sample per line. Commas, tabs, semicolons or spaces all work, and a header row is detected and skipped. Three layouts are accepted: a single value column, with times generated from a sampling interval you supply; time,value; or time,value,SD when your assay reports per-sample precision. A downloadable example is linked from the app's About page.

Igor packed experiments (.pxp) and binary waves (.ibw) also load directly, with a picker for the data wave and its error and time waves. A wave's own x scaling supplies the sampling interval and its x units set the time axis.

The Sample data menu holds two kinds of dataset, and they must not be mistaken for one another.

Real animals. Eight records read off the published figures of Webster et al. (1991) — four ewes, portal GnRH during collection and jugular LH from a fortnight earlier. These are measurements from actual sheep, approximate to the width of a printed line. Each figure marks the pulses that paper's own CLUSTER run identified, so these records carry a published, human-checked answer — an answer about what a detector reported, not about what the animal secreted.

Simulated models. Generated from a seeded random number generator. They correspond to no animal, experiment or measurement. What they carry is the opposite kind of truth: every pulse was placed deliberately, so they measure what a detector misses and invents. Their scales — sampling interval, baseline, pulse amplitude, pulse frequency, pulse shape and assay CV — are taken from the published literature rather than chosen for appearance. The simulated GnRH records were built to the same paper's protocol, which is why they resemble the real ones; none of them corresponds to any animal in it. Portal GnRH and peripheral LH are generated by different models: GnRH is collected as integrated fractions and cleared far faster than a collection window, so its pulses are square bursts occupying one or two samples with no decay tail, while LH's half-life is long relative to the sampling interval and exponential clearance applies. A flat negative control is included deliberately: a detector should find nothing in it.

What the reported quantities are called

Peak value is the highest concentration reached inside a pulse — an absolute level, printed by the Fortran as HEIGHT. Amplitude is that value minus the mean of the nnadir points before pulse onset — the rise the pulse represents, printed by the Fortran as L INCREASE. The two are routinely conflated; both are reported here, per pulse and as a mean ± SD, alongside the nadir they are computed from.

Pulse frequency is counted over the whole record, not as the reciprocal of the mean interpulse interval — the latter spans only the first pulse to the last, and reads high on records with long quiet stretches.

Batch processing: running this without the browser

The page above is built for looking at one record. A study is two hundred of them, and clicking stops scaling somewhere around fifty. The same algorithm runs from a command line over a whole directory, writing one summary row per record — the table you would paste into R, SPSS or a spreadsheet.

It lives in the source repository, github.com/syncytium2/no_peak (MIT). Clone it, npm install, and:

node scripts/cluster.ts records/ -o summary.csv
node scripts/cluster.ts records/ --preset webster1991_lh -o summary.csv
node scripts/cluster.ts one.csv --n-peak 3 --t-up 2.5 --unit min -v
node scripts/cluster.ts --help
node scripts/cluster.ts --list-presets

Input is files or directories, in the same three text layouts the app accepts. Output is CSV on standard output, or to -o. There is no build step, no loader and no extra dependency: plain node runs it.

What it writes

One header row, then one row per record: segment, n_points, duration, n_pulses, pulses_per_unit, mean_interpulse_interval, mean_peak_value, mean_amplitude, mean_pulse_width, mean_nadir. Above them, comment lines carrying the version, the detection settings, the time base, and — when a published preset was used — the citation those settings came from. Provenance travels with the numbers, the same rule every other export here follows.

The settings

FlagDefaultWhat it sets
--n-peak2Points averaged in the test window
--n-nadir2Points averaged in the baseline window
--t-up2t-score for a significant increase
--t-dn2t-score for a significant decrease
--min-peak0Minimum data value for a pulse
--error-modelLocal SDOne of the seven models above; a file with an error column switches to Error Wave automatically
--error-value1Value for Fixed, and SQRT's fallback for non-positive data
--variantigorigor or fortran — see “Which implementation you are running”
--presetA published parameter set, with its citation
--unitmins, min, h, or samples
--interval1Sampling interval, for files with no time column
-ostdoutWhere to write the summary CSV
-voffAlso list every pulse, on standard error
--strictoffStop on the first unreadable record instead of skipping it

Three properties that make a batch result comparable

One parameter set for the whole batch. Tuning the detector per animal makes the pulse counts incomparable between animals, because the parameters are the model. The command line has no way to vary them per record, deliberately.

Records are still analyzed independently. Nothing is concatenated and no averaging window ever spans two records, so no pulse is invented at a join and no interpulse interval is measured across one.

It is the same code as this page's app. The summary table comes from the function behind the Per-record CSV button, called rather than reimplemented, and the test suite re-runs through the command line the pulse counts it checks through the library — including the 11 GnRH pulses Webster et al. (1991) mark in the digitized THX ewe. A batch run and a run in this browser cannot report different numbers for the same data without a test failing.

What it is not. It reports pulse statistics; it does not score a detector against known pulse times. If you have ground truth and want sensitivity and false-discovery rates, that is tools/score_benchmark.ts in the same repository, and the numbers it produces are the ones in Validation against known ground truth above.

Privacy is architectural, not a promise

no_peak is a static bundle served from object storage. There is no server-side script, no database, no account, and no analytics. The algorithm is compiled into the page and runs on your own device, so there is no code path along which a loaded recording could be transmitted anywhere — the guarantee is a property of the architecture rather than of a policy document. That is the relevant property if your recordings are human-subject data.

Credit

CLUSTER is the work of Michael L. Johnson and Johannes D. Veldhuis. Johnson, a biophysicist and Professor Emeritus of Pharmacology at the University of Virginia, spent a career building the numerical methods and freely distributed software that much of the endocrine pulsatility literature rests on. There was never a separate software paper for CLUSTER; the program was distributed directly by the authors, and the field cites the 1986 paper below as the citation for the program itself. This port exists because that software was shared openly and kept working for four decades. Any errors in the translation are ours, not theirs.

The original Fortran and the Igor Pro package are third-party code that is not redistributable and is not included in this project. Real laboratory recordings are not distributed either.

References

Every citation here was checked against PubMed or publisher records.

The algorithm

Other detectors, and when to prefer them

Applied to firing rate