Changelog
Source:NEWS.md
callDensity 1.3.0
New features
-
Union call density estimation. Until now, every capture-recapture density estimate – however many observers or detectors were fit jointly – was still reported for one of them:
whichObserverpicked which detector’s own detection probability to read off the fitted model, andcde()’sobserverColpicked which detector’s own raw counts and false discovery rate to use. The joint model always computed the probability that at least one detector caught a given call as an internal step toward each individual detector’s own probability (VGAM::posbernoulli.t’stype.fitted = "onempall0") – this was simply never surfaced. It now is.whichObserver = "any"(matching the same argument’s existing single-observer values, not a new mechanism) requests the union detection probability directly. Paired with the matching union of raw detections (newunionDetections()) and a union false discovery rate (falseDiscoveryRate()’stestColNamenow accepts a vector, andchtToSNRinfo()’sDetectedis the union across all namedobservers, not just the last one – identical to previous behaviour when only one is named),cde()’sobserverColaccepts the matching vector of detector columns and computes a single call density estimate drawing on every detector’s own detections at once, rather than several separate, partially-redundant analyses of the same underlying calls.cde()checks that a vectorobserverColand awhichObserver = "any"model agree with each other, and refuses to silently combine a union false discovery rate with a single detector’s own detection probability, or the reverse.New vignette
callDensity_unionDetectors.Rmddemonstrates this end to end against simulated data: fits one joint model, reads off each detector’s own curve and the union curve from it, and compares all three against known truth. The union’s false discovery rate is unconditionally more precisely estimated than any individual detector’s own, since it pools every event any detector flagged rather than just one detector’s own flags. That much is guaranteed by construction. Detection probability’s own uncertainty is a separate quantity the union construction does not unconditionally improve, so whether the union’s overallCV.Dcbeats an already-well-identified individual detector’s own was left as an open question through most of this release’s development. Measured against corrected simulations, it does: reportedCV.Dcfalls monotonically from 2 through 10 detectors, as does the realized spread ofD_cacross replicates. The vignette shows both rather than asserting either.A further section extends the comparison to 2, 4, 6, 8 and 10 detectors of randomly varied quality, and adds a violin plot of adjudicated-true-positive SNR by number of detectors agreeing, in the spirit of Miller et al. (2026)’s Figure 3. The false-positive half of that figure is not reproduced:
simulateDetector()now guarantees each detector’s false positive times are distinct from every real call time and from each other, so two detectors agreeing on a false alarm is impossible rather than merely unlikely, which would make that comparison an artifact of the simulation rather than a real finding. Correlating false positives across detectors remains an open simulation limitation. -
New
jackknifeDetectors(): refits a union call density estimate once per contributing detector, each time leaving that detector out, and reports how far each leave-one-out estimate moves from the full-set one. Structurally leave-one-out refitting like leave-one-out cross-validation, but aimed at a different target – cross-validation estimates prediction error on held-out data, whereas this estimates the estimator’s own sensitivity by watching how much it moves as each unit is removed, the unit here being a whole detector rather than a data point.This sees a source of uncertainty
cde()’s ownCV.Dcstructurally cannot: that CV comes from a parametric bootstrap over the coefficients of one fitted detection function, so it propagates uncertainty conditional on the detector set and the fitted curve, and has no way to represent how differently the analysis would have gone with a different mix of detectors. The jackknife CV typically comes out larger for exactly that reason – the two are complementary, and quoting both is more honest than quoting either alone. Needs no ground truth and costs N refits, so it’s runnable on real data where the replicate study that would otherwise answer this question isn’t. simsTocaptureHistoryTable()accepts any number of detectors (was fixed at exactly two), via...rather than named arguments – every existing two-argument call site is unaffected. Output is matchbox- native throughout:key,t0/tEndas genuine MATLAB datenums (newRdate2mat(), the inverse ofmat2Rdate()– a plain POSIXct under the same column name silently breakschtToSNRinfo()’s defaulttimeCol = 't0'andcde()’s auto-detection, both of which unconditionally convert anything literally namedt0),detect_<suffix>(always clean logical), and every other column suffixed per detector rather than pre-averaged – downstream consolidation (e.g. averaging SNR) ischtToSNRinfo()’s job now, via its existingsignalCol/noiseColvector-averaging.groundTruthis coalesced into a single event-level column alongside the per-observer copies: unlike SNR or a detector’s own true/false-positive label, ground truth is a property of the event itself, and every contributing detector’s own copy of it agrees whenever more than one is present. Merging is on exactdatetimeequality, so every event must have a unique time. This is now checked rather than assumed; see the bug fix below.-
New
checkDetFun(): reports whether a fitted detection function is well conditioned enough to trust its covariance matrix. This matters becausepDetInArea()propagates detection function uncertainty by drawing coefficients fromMASS::mvrnorm(coef(detFun), vcov(detFun)), so an unreliablevcovmakes the reportedCV.paunreliable with it.VGAM::vglm()emits warnings freely. Aposbernoulli.tfit with ten occasions routinely produces a dozencheckwzmessages plus “some quantities such as z, residuals, SEs may be inaccurate due to convergence at a half-step”, which VGAM emits whenever IRLS damps its final step without checking whether the SEs are in fact affected. Most of it is benign, and there was previously no way to tell benign from real without readingvcovby hand.checkDetFun()reports the things that do indicate trouble: the condition number ofvcov, the spread of the standard errors, the largest absolute coefficient, and the IRLS iteration count. An optionaltrueParamsargument adds a parameter recovery table for simulation work, comparing fitted-intercept/slopeagainst knownlocationand fitted slope against1/scale. -
subsampleSimInTime()gainstimeCol, defaulting to matchbox-native't0'.cde()consumes capture history tables, so the table is the natural object to subsample. Subsampling each detector separately and merging afterwards does twice the merging for the same result, and gives every detector its own fabricated false positives to be merged around. Subsampling the merged table also makes the subsample a provable subset of the full-year table, which building the two separately did not.timeColfalls back to'datetime'when't0'is absent, so a rawsimulateDetector()table still works with no argument. A numeric column is treated as a MATLAB datenum and converted withmat2Rdate(), matching howchtToSNRinfo()andcde()already treatt0. Errors rather than silently dropping rows if the time column containsNA.
Bug fixes
-
Duplicated event times produced a 2^N cartesian blow-up in
simsTocaptureHistoryTable().merge()on a duplicated key returns the cartesian product of the matching rows.simCallLocation()drew call times withrunif()and never checked for collisions, and every detector’s table contains all ofsim, so a single duplicated call time appeared in all N detector tables and an N-way merge turned that one event into 2^N rows, every one of them inheritinggroundTruthfrom the real call.simulateDetector()had the same gap for false positive times, documented as having “a negligible chance of colliding” and never checked. Probabilistic reasoning is not sufficient here: two doubles either compare equal or they do not, andmerge()cares only about the latter.The impact scaled with the denominator, which is why it hid for so long. Against a full year’s roughly 630,000 detections, 1024 spurious rows are 0.16% and invisible, so
Nclooked stable. Against the roughly 24,500 real calls in a 41-hour adjudicated subsample the same rows are 4% per duplicated time, and the number of duplicated times varies by simulation seed, so the adjudicated sample size varied 13% between replicates at ten detectors where sampling allows about 1%. Everything estimated from the adjudicated subsample inherited that: the false discovery rate, the fitted detection function, and through themp_aandD_c.Fixed with new internal
uniqueEventTimes(), which resamples collisions rather than assuming they are rare, comparing on the numeric representationmerge()itself compares, plus a guard insimsTocaptureHistoryTable()that fails loudly on a duplicated join key instead of silently returning a cartesian product.This changes simulation output for any given seed. Results generated under earlier versions do not reproduce under this one. It also withdraws a finding:
callDensity_unionDetectors.Rmdpreviously reported thatcde()’sCV.Dcunderstated the realized spread ofD_cacross replicates, sometimes by a factor of two. That was computed on corrupted adjudicated tables at ten detectors, the configuration where the bug bit hardest, and does not survive the fix. On corrected simulations the reported CV is conservative rather than optimistic, and nominal 95% intervals cover the truth at or slightly above nominal rate. groundTruthwas numeric, not logical.simCallLocation()set it to1, a double, andrbind()withsimulateDetector()’s logicalFALSEfor false positives coerced the whole column back to double.which(cht$groundTruth)therefore errored, andsubset(cht, cht$groundTruth & ...)worked only through silent numeric coercion. The roxygen had always described it as logical. Now set asTRUEat source and forced logical insimsTocaptureHistoryTable()’s coalesced column.chtToSNRinfo(): unjudged and “OTHER” (verdict = -1) rows were silently inserted as phantom all-NA rows.cht[cht[[gtCol]] == 1, ]producesNAwherevergtCol(verdict) isNA, and R’sdf[cond, ]with anNAincondinserts a placeholder row for it rather than dropping it – on a table with mostly-unjudged rows (the normal shape of a real capture history table), this could return far more rows than there were genuine true positives, nearly all of them garbage. Fixed withwhich()in place of direct logical indexing, which correctly dropsNAinstead of keeping a placeholder for it.nlFromDetections()now filters toDetected == TRUEinternally when that column is present, rather than assuming its caller already had.cde()was passingchtToSNRinfo()’s full (detected and missed) output straight through unfiltered;nlFromDetections()’s own bias correction assumes it’s been handed noise measured at detections specifically, so an unfiltered input overcorrects an already-close-to- unbiased mean. Invisible in the existing observer-ground-truth convention (a relatively balanced detected/missed split keeps the effect under a dB, unnoticeable against ordinary simulation noise), but substantial – several dB, a large swing in the resultingDc– for a capture history table with a more lopsided split, which the union convention’s coalesced ground truth (every real call, not just ones some detector already flagged) produces.showDetFun()’s internalextractSNRinfo()errored on awhichObserver = "any"model (model@y[, "any"]: no such column). Detected is now the union across every modeled occasion for this case, matching the same semanticvglmDetectionProb()/pDetInArea()/cde()already use for it.
Documentation
callDensity_unionDetectors.Rmdcaches its expensive results. Thecde()andpDetInArea()calls are cached tovignettes/precomputed/*.rdsand read back unlessrecompute <- TRUE, so editing prose no longer costs hours. The simulations themselves are deliberately not cached, being roughly 100 MB each atn = 1e6and only minutes to rebuild.Vignettes are no longer built during
R CMD check.callDensity_unionDetectors.Rmdruns atn = 1e6with ten detectors and will not fit a GitHub runner’s memory, quite apart from its runtime.parallel_benchmarks.Rmdtimes parallel backends, which is meaningless on a two-core runner and is the same class of fragility as thefuture::multisessionCI hang fixed in 1.1.1. Vignettes are knitted locally before release and the rendered site is committed underdocs/. The trade is that CI will no longer catch a broken vignette.outerloopguidance. The union vignette now setsouterloopexplicitly and says why. Measured on a fitted ten-detector union model, three quantities converge at different rates: the point estimatep_ais stable byouterloop = 5, the expected value ofCV.pais also stable byouterloop = 5, and only the run-to-run precision ofCV.pakeeps improving, from roughly 20% of the CV itself at 5 to about 2% at 250, with no measurable gain at 1000. The package default of 1000 remains, but 250 is sufficient wherever a CV is reported.
callDensity 1.2.0
New features
-
chtToSNRinfo()replaces the three-functionmchToCR()/capHist2snrInfo()/capHistTosnrInfo()sprawl with one converter, and adds genuine N-observer capture-recapture support that no function in the package had before. These three grew independently as the package’s capabilities expanded, without ever settling on one contract: two assume a 2-observerdetect_table1/detect_table2shape that requires renaming native matchbox columns before use;capHistTosnrInfo()additionally assumes SNR was measured before matching (a per-observersignalRMSdB1/signalRMSdB2pair, averaged) rather than after (matchbox-native: one shared value per matched event); and none of the three can actually feedfitDetFun(modelType = 'vglm'), despitecapHistTosnrInfo()’s own documentation saying it could –vglm’sposbernoulli.tneeds the raw per-observer 0/1 columns still present in the table, and both functions collapse them into a singleDetectedcolumn before returning. Every real capture-recapture analysis using this package has had to hand-build its VGLM input as a result.chtToSNRinfo(cht, groundTruth, observers, ...)fixes all three problems at once. It defaults to native matchbox column names (t0,signalRMSdB,noiseRMSdB,verdict,detect_observerN), accepts any number ofobservers(not just two –VGAM::posbernoulli.twas never actually limited to two occasions, it just hadn’t been exercised with more in this package before now), and keeps the raw observer columns intact in its output so the result can be passed straight tofitDetFun(modelType = 'vglm', yColNames = c(groundTruthCol, observerCols))with nothing hand-built. A single-elementobserversreproduces the old 2-observer OG shape exactly, so this isn’t a second code path alongside a simpler one – OG is just the N=1 case of the same function. cde()gainsgroundTruthCol/observerColarguments, forwarded to bothfalseDiscoveryRate()(which already accepted them, but couldn’t be reached –cde()called it positionally without forwarding) and the newchtToSNRinfo().cde()can now run directly against a native matchboxcapHistTab(verdict,detect_observerN) with no reduction step, in addition to the legacydetect_table1/detect_table2shape it already supported. Defaults are unchanged ('detect_table1'/'detect_table2'), so no existing call site is affected.cde()also gainssnrColName/timeColarguments, same reasoning asgroundTruthCol/observerColabove and found the same way – by actually runningcde()against raw simulation data.snrColName(default'SNR') is forwarded tofalseDiscoveryRate(), which already accepted it but wasn’t reachable fromcde(); raw simulation/detection tables typically carry lowercasesnrinstead.timeCol(defaultNULL, auto-detecting't0'then't'as before) is forwarded to the internalchtToSNRinfo()call, for tables using neither (e.g.'datetime').cde()gains opt-inreturnDetFun/returnPdetDetailarguments, both defaultFALSE. When requested, the detection function actually used (whether passed in or fit internally) and/orpDetInArea()’s full result (including the per-transect detail needed for a probability-of-detection-vs-range plot) are attached as attributes on the returned data.frame (attr(result, "detFun")/"pDetResults") rather than changing its shape –resultis always the same plain data.frame either way, sorbind(),result$Dc, etc. all keep working unchanged whether or not these are requested.pDetInArea()now returnsallDetFunctions, the full range-by-transect probability-of-detection grid that was already being computed internally but previously only escaped via the optionaltransectFileCSV write. Purely additive (a new named list element, not a fixed-shape data.frame), so existing callers accessing$overall/$perTransectMeanSD/$meanOfAllTransectsare unaffected.New
plotPDetRadials()plots probability of detection as a function of both range and azimuth around the recorder – a directional detection-range footprint, rather than the 1D range-only viewmeanOfAllTransectsgives. Ports Brian’s MATLABplotPDetRadials.mto a native ggplot2coord_polar()plot. TakespDetInArea()’s ownallDetFunctionsoutput directly.Vignettes reworked to use existing package functions instead of hand-rolled equivalents, closing a gap where the package’s own capabilities (
chtToSNRinfo(),fitDetFun(),showDetFun(),predictDetFun(),plotSpatialDetections(), and nowcde()’s new opt-in returns) had grown without the published examples being updated to demonstrate them.callDensity.Rmd– the flagship vignette, same name as the package – previously reimplementedcde()’s own density formula by hand, three times over (once per detection-function model type), silently dropping every uncertainty column (CV.Dc,CV.pa,CV.c)cde()computes automatically; now usescde()directly, and gained two diagnostics it never had before (showDetFun()’s fitted- curve-vs-observed-SNR plot, andplotPDetRadials()’s directional footprint) rather than only ever plotting probability of detection vs. range.parallel_benchmarks.Rmd,callDensity_CommonGround.Rmd,callDensity_coast.Rmd, andcallDensity_snrThreshold.Rmdupdated similarly;callDensity_CommonGround.Rmd’s capture-recapture section had its ownTODO: ... make this an explicit parametercomment, directly resolved bycde()’s newgroundTruthCol/observerCol.simsTocaptureHistoryTable()defaults to matchbox-nativedetect_observer1/detect_observer2column naming (wasdetect_table1/detect_table2). NewobserverSuffixargument, defaultc('observer1','observer2'); passc('table1','table2')for the previous naming. Every vignette’s simulated example now demonstrates the same column conventionchtToSNRinfo()defaults to, rather than teaching the older convention by example while the documented default points elsewhere.
Deprecated
-
mchToCR(),capHist2snrInfo(), andcapHistTosnrInfo()are deprecated in favour ofchtToSNRinfo(). All three still work exactly as before – bodies unchanged (mchToCR()) or reimplemented as thin wrappers with identical output (capHist2snrInfo(),capHistTosnrInfo()) – so the published Common Ground and Beyond Counting Calls analysis scripts keep reproducing against currentmainwithout modification. New analyses should callchtToSNRinfo()directly; none of the three deprecated functions’ 2-observer-reduction/renaming machinery is needed by any current workflow, since neitherfitDetFun(modelType = 'vglm')nor (as of this release)cde()requires it.This is a deliberate streamlining, not a stopgap: going forward, one function covers OG and CR alike, for any number of observers, on native matchbox column names by default – with the old interfaces kept working, not removed, so nothing currently in production or published breaks.
callDensity 1.1.1
Reworks the callDensity_snrThreshold vignette around the actual failing
scenario the truncation feature addresses, rather than an arbitrary
example. Drops the observer-ground (OG) design from that vignette
entirely (covered elsewhere, in callDensity_CommonGround); fixes a detection-function
plot that was showing the truncated fit extrapolating past its own
support while hiding true ground truth in the same region; and adjusts
the false-positive simulation parameters to match the qualitative pattern
in Miller et al. (2026) Figure 3, with an explicit note that the
resulting truncation-lowers-c effect is not a general result.
callDensity 1.1.0 (unreleased)
Bug fixes
pa_CV()computed the wrong standard error, inflatingCV.paand thereforeCV.Dcand every confidence interval derived from it. Between 2025-06-27 and this release,se_pawas computed asoverall_weighted_mean_pa / sqrt(no.transects)– the point estimate divided by a constant, not a standard error of anything. With the package’s typical transect count this pinsCV.pawithin a whisker of1/sqrt(no.transects)almost regardless of the underlying data. Restored to the correct standard error of the transect means, generalised to unequal transect weights viaHmisc::wtd.var(reduces exactly to the original unweighted formula when weights are uniform). Verified by mutation testing and against real study data (16 site/observer/detector combinations): the buggyCV.paran 1.5x to 4.6x the corrected value, mean 2.3x. Anyone who has usedcde()and reportedCV.pa,CV.Dc, or confidence intervals onDcsince 2025-06-27 should recompute them with this version.SNR truncation (
snrTruncationThreshold) set detection probability toNAbelow the threshold; it should be0.NAdrops a cell from both the numerator and denominator ofpDetInArea’s range-weighted mean, sop_awas computed asE[p | SNR >= theta]andcde()returned the density of above-threshold calls, silently scaled down from the density of all calls by a factor ofq(theta)(the fraction of calls arriving above the threshold). Zeroing instead givesE[p * 1(SNR >= theta)], soq(theta)cancels against a correspondingly-truncatedNcandcde()now returns the density of all calls, matching its documented behaviour. Distance truncation is unaffected – it still usesNA, correctly, since cells beyond the truncation distance genuinely leave the study area rather than merely becoming undetectable.countDetections()hardcodeddet$snrdespite taking ansnrColNameargument, so truncating with any other column name (e.g."SNR", which is whatcapHistTabactually uses) silently returnedNc = 0with no error. Fixed to use the column the caller names.cde()called the deprecatedfitSNRdetectionFunc()internally, which was already a pure passthrough tofitDetFun(). Every user who letcde()fit its own detection curve got a spurious “fitSNRdetectionFuncis deprecated” warning for a function they never called.cde()now callsfitDetFun()directly.
New features
SNR truncation is now fully supported, not experimental.
cde()gainsNcIsTruncated, a required confirmation wheneversnrTruncationThresholdis set:cde()receivesNcas a plain number and never sees the detections it was counted from, so it cannot truncate or verify it itself. An untruncatedNcpaired with a truncatedp_awould silently inflateDcby roughly1/q(theta). IfsnrDetFunis supplied alongside a threshold,cde()now also messages a reminder that the curve should be fitted on the truncated sample, since it cannot refit a model it did not fit.nlFromDetections()is nowcde()’s default noise-level estimator, replacingnlFromSnrInfo(). Both correct the same bias (noise levels measured at detections are biased low, because detections over-represent quiet periods), butnlFromSnrInfo()did so by adding back the SNR at which the detection function reaches 0.5 – a property of the detector – while the bias itself is a property of the propagation geometry and the noise variance. The two coincide only by chance.nlFromDetections()inverts the actual detection-probability weighting instead, so it generalises across detector shapes. Works withglm/gam/scamandvglmdetection functions alike, via the package’s own dispatch-awarepredictDetFun(). PassNLexplicitly to use a different estimate, as before.callDensity_snrThresholdvignette completed: theq(theta)cancellation that makes truncation return all-call density, and arecaptureCoverage()diagnostic for choosing the threshold from where recaptures (not raw sample coverage) run out.
Deprecated
-
cdeFromParamFile()is deprecated. It had drifted into a near-duplicate ofcde()’s body: it calledpa_CV()without transect area weights (cde()always weights bytruncationDistance^2) and had no equivalent ofcde()’sNcIsTruncatedguard. It is now a thin wrapper that derivescde()’s arguments from its parameter object and callscde()directly, so it inherits every fix above rather than carrying its own, separately-unmaintained copy of the calculation. Usecde()directly going forward.
callDensity 1.0.1
Not tagged as a release at the time. Folded into this NEWS file retroactively for a complete version history; see git history for exact commits.
New features
- New unified detection-function interface:
fitDetFun(),predictDetFun(),showDetFun(), replacingfitSNRdetectionFunc(),fitSNRvglm(), andfitSNRvgam()(retained as deprecated wrappers). Supportsglm,gam,scam, andvglm(capture-recapture) models behind one interface, with proper S3 dispatch for prediction and plotting – including forvglmobjects, whichstats::predict()cannot handle directly. -
showDetFun(): plots a single model (with SNR-info overlay, rug, optional mirrored density/histogram) or a named list of models for multi-curve comparison. -
pDetInArea(): parallel execution viafuture.apply;truncationDistancedefault fixed to referenceTLlookup;snrTruncationThresholdargument added (see 1.1.0 for the fix to how it’s applied).
Deprecated
-
fitSNRdetectionFunc(),fitSNRvglm(),fitSNRvgam(), and the oldpredict/showfunctions superseded by the unified interface above.