All files / src/analysis yapc.ts

99.24% Statements 526/530
97.68% Branches 211/216
100% Functions 31/31
99.24% Lines 526/530

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994                                                                                                                              2x             2x   2x   2x   2x   2x 2x           2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x             2x     2x                                           2x 42x 42x 42x 42x 42x 42x 42x 42x 42x     111x 111x 111x 33195x 33195x 33195x 33195x 33195x 33195x 111x 111x                       2x 267x 267x     2x 70x 70x 70x                                                                           2x 93x 93x 93x 93x 93x 93x 93x 93x 93x 93x 3735527x 3735527x 8x 8x 8x 3735519x 3735519x 3735519x 3735527x 93x 93x 3733165x 3733165x 3733165x 3733165x 3733043x 3733043x 3733043x 93x 93x 93x 93x 93x 93x 93x 93x                 2x 2x 2x 2x 2x 2x 2x 2x                     2x 24x 15x 15x 15x 15x 15x 24x 1799258x 1799258x 1799258x 3598516x 3598516x 3598516x     3598516x     3598516x 3598516x 3598516x 1799258x 1799258x 1799258x 1560692x 1560692x 1560692x 1560692x 1560692x 1799258x 1799258x 15x 15x 15x 15x 15x 15x 15x     5534677x 5534677x 5534677x                                                                             2x 122x 122x 122x 122x 122x           122x             122x 122x 122x 122x 122x 122x 29x   29x 29x 360063x 360063x 360063x 360063x 360052x 360063x 360063x 360063x 360063x 29x 122x     40x 2229647x 2229647x 2229647x 2229647x 40x 122x 122x                                                     360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x 360145x   360145x 360145x 360145x   360145x 360145x 360145x 6188261x 6188261x 3014943x 6188261x 360145x 360145x 360145x 12399690x 12399690x 12042475x 12042475x   12399690x 12399690x 12399690x 12399690x 523008x 523008x 514159x 514159x 514159x 523008x 523008x   12399690x 12399690x 12399690x 11922892x 10983993x 11922892x 938899x 191719325x 191719325x 938899x 11922892x   12399690x 12399690x 12399690x 55725x 55725x 55725x   12399690x 12399690x 12042475x 12399690x 12399690x 522824x 522824x 522824x 522824x 12399690x 360145x                                                       2x           2x 6896155x 6896155x             2x 15x 15x 15x 15x                                                       2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x   2x 2x   2x 2x 2x 2x   2x 31x 31x   31x 31x 31x 31x 31x 31x 31x                     2x 4116x 4116x 4116x 4116x 4116x 4116x     4116x 4820398x 4820398x 4820398x 4820398x 31x 4820398x 347234x 347234x 4820367x 347231x 347231x 4820398x     1699291x 1699291x 816579x 816579x 1699291x 11793x 11793x 1699291x 1699291x 1699291x 4820398x 4820398x 4116x 4116x     2x 31x 31x 7x 7x 7x 7x 7x 7x 31x 31x 31x 31x 31x 31x 31x 31x 31x   2x 347249x 347249x 347249x 347249x 347249x       347249x 347249x   2x 1699291x 13x 19x 19x 19x 13x 13x 1699291x 1699291x 1699291x   2x 347249x 347249x 347249x 347249x 347249x 523731x 523731x 523731x 523731x 347249x 347249x 2x                   2x 58x 58x 58x 58x 58x 58x 11493x 11493x 11493x 11493x 11493x 11493x 58x 58x                             2x 41x 41x 41x 41x 41x 41x 41x 41x 41x 57x 57x 57x 41x 451766x 41x 41x 41x 41x 41x 41x                                                               2x 2x 2x 2x 2x 2x 2x 2x   2x 2x 2x 2x   2x 2x   2x 30x 30x 30x               30x 30x 30x 30x 30x 30x 30x     2x 52133x 52133x 52133x 71176x 71176x 1x 1x 1x 71176x 71176x 52133x 11152x 11152x 11152x 52133x 40981x 40981x 40981x 40981x 40981x 40981x 52133x 52133x 52133x     2x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x     2x 33x 25x 33x     33x 33x 33x 25x 33x 28700x 28700x 28700x 11389x 11389x 11389x 28020x 5076x 5076x 5076x 5076x 5076x 5076x 5076x 17311x 17311x 25x 25x 33x   2x 22541x 22541x 22541x 8x 8x 8x 8x 10439x 22541x   2x 26x 140x 140x 140x 140x 26x 26x 26x 26x 26x 26x 2x                                                   2x 3939x 3939x 3939x 3939x 3939x 3939x 3939x 1003x 1003x                 2x 16x 16x     2x  
/**
 * yapc's peak calling, as a port (§18.8, §18.9).
 *
 * *Yet another peak caller* (Jänes et al. 2018, eLife 7:e37344) reads a
 * **coverage track** rather than reads, which is what makes it the one caller
 * this app can run: a bigWig is all a browser has of an ATAC or a ChIP
 * experiment, and yapc was written for exactly that input — "processed signal
 * tracks … that show the most compelling peaks by visual inspection". Its idea
 * is a shape and not a threshold. The signal is smoothed, differentiated twice,
 * and every stretch where the smoothed second derivative is negative — where
 * the track **curves down** — is a candidate peak, scored by how sharply it
 * curves. A peak is found by its form rather than by its height, so the call
 * needs no background model of a signal that has already been normalised, and
 * two summits standing on one shoulder are two peaks rather than one wide one.
 *
 * What a candidate is worth is then a question for **two replicates**: each
 * scores every candidate, and IDR (`analysis/idr.ts`) asks how far down the two
 * rankings agree. With one track there is no such test, and the run says so.
 *
 * ## A port, and what that obliges
 *
 * §18.9's rule for a second caller is that it arrives with a published upstream
 * and a golden built from that upstream's own output, and that the port does
 * not improve on it. The upstream is yapc 0.1 — the release on bioconda, which
 * the paper's methods describe — and every step below is its code, in its order,
 * including four things a first reading takes for oversights:
 *
 * - **The pooled track and the concave regions are read back through a
 *   bigWig.** yapc writes the mean coverage and its second derivative to disk
 *   and calls regions on what it reads back, so both are **float32** by the time
 *   anything is decided on them, and a tie between two positions of equal
 *   curvature is a tie in float32. The *scores* are computed from each track in
 *   float64 and never written, so they are not rounded. Both roundings are kept.
 * - **`np.convolve(…, mode='same')` centres an even kernel half a base right**,
 *   and on a sequence shorter than the kernel it centres on the kernel instead
 *   — after which pyBigWig silently keeps the first `length` values. Both
 *   alignments are reproduced ({@link convolveSame}).
 * - **Flat signal is concave.** The test is `d2 < 1e-10`, and the composite
 *   kernel sums to a few times −10⁻¹¹ rather than to zero in float64, so a stretch of
 *   constant non-negative coverage — a run of zeros included — comes out as a
 *   concave region with a score near zero. It sorts to the bottom of every
 *   ranking, which is where it does no harm, and it is there.
 * - **A sequence whose concave stretches never begin after its first base has
 *   none.** `find_concave_regions_chrom` returns early when there is no rising
 *   edge, before it adds the region open at position 0 ({@link ConcaveScan}).
 *
 * Where yapc does not produce an answer at all, the port has to, and each place
 * is named where it is decided: a sequence ending on an open concave stretch
 * with no rising edge before it (yapc raises an `IndexError`), a fixed
 * half-width reaching past a sequence end (yapc writes a negative BED start),
 * and a tie in rank at the truncation cut (yapc's `quicksort` puts it wherever
 * numpy's introsort left it; here it is broken by position).
 *
 * ## Streaming, which yapc does not do
 *
 * yapc holds a whole chromosome per sample in memory — 2 GB for a human
 * chromosome 1 in float64. Nothing here needs more than one window at a time:
 * the convolution reaches 228 bases each side, so a window read with that halo
 * is computed exactly as the whole sequence would be, and {@link ConcaveScan}
 * carries an open region, its summit candidates and its values across the seam.
 * The answer is the same function of the same numbers; only the memory is not.
 */
 
import { numpyMean } from './stats'
 
// ---------------------------------------------------------------------------
// The kernel
// ---------------------------------------------------------------------------
 
/** yapc's own defaults, from its `argparse` help. */
export const YAPC_DEFAULTS = {
  /** `--smoothing-window-width`: "try … values ranging from 75 to 200". */
  width: 150,
  /** `--smoothing-times`. */
  times: 3,
  /** `--min-concave-region-width`. */
  minWidth: 75,
  /** `--truncate-idr-input`. */
  truncate: 100_000,
} as const
 
/**
 * The nine-point central difference for a second derivative, accurate to eighth
 * order — the weights yapc cites (Fornberg 1988, *Math. Comp.* 51:699).
 */
export const SECOND_DERIVATIVE_STENCIL: readonly number[] = [
  -1.0 / 560,
  8.0 / 315,
  -1.0 / 5,
  8.0 / 5,
  -205.0 / 72,
  8.0 / 5,
  -1.0 / 5,
  8.0 / 315,
  -1.0 / 560,
]
 
/**
 * yapc's `ADHOC_LARGE_NUMBER`: "scale to have better visualisation of d2smooth
 * in IGV". It multiplies every score, so it is part of what the numbers mean
 * and is kept, whatever it was for.
 */
export const KERNEL_SCALE = 1e6
 
/** `find_concave_regions_chrom`'s `tol`: a position is concave below it. */
export const CONCAVE_TOLERANCE = 1e-10
 
/** The composite kernel, and its running total for the run-wise convolution. */
export interface YapcKernel {
  readonly width: number
  readonly times: number
  /** `prepare_second_derivative_kernel(width, times)`: scaled and reversed. */
  readonly values: Float64Array
  /** `cumulative[m]` is the sum of `values[0, m)`; one longer than `values`. */
  readonly cumulative: Float64Array
}
 
/**
 * `prepare_second_derivative_kernel`: the stencil convolved `times` times with a
 * rolling mean of `width`, scaled, and reversed.
 *
 * One convolution with this is the smoothed second derivative, which is why yapc
 * builds it rather than smoothing and then differentiating: the composite is 456
 * taps at the defaults and applied once. The reversal is a no-op on a symmetric
 * kernel and is kept anyway, because the float64 composite is symmetric only to
 * about one part in 10¹⁴ and it is yapc's orientation that the numbers are in.
 */
export function yapcKernel(width: number, times: number): YapcKernel {
  let kernel: Float64Array = Float64Array.from(SECOND_DERIVATIVE_STENCIL)
  const box = new Float64Array(width).fill(1 / width)
  for (let pass = 0; pass < times; pass++) kernel = convolveFull(kernel, box)
  const values = new Float64Array(kernel.length)
  for (let k = 0; k < kernel.length; k++) values[k] = KERNEL_SCALE * kernel[kernel.length - 1 - k]
  const cumulative = new Float64Array(values.length + 1)
  for (let k = 0; k < values.length; k++) cumulative[k + 1] = cumulative[k] + values[k]
  return { width, times, values, cumulative }
}
 
/** `np.convolve(a, b)` in its default `'full'` mode. */
function convolveFull(a: Float64Array, b: Float64Array): Float64Array {
  const out = new Float64Array(a.length + b.length - 1)
  for (let n = 0; n < out.length; n++) {
    let total = 0
    const from = Math.max(0, n - (b.length - 1))
    const to = Math.min(n, a.length - 1)
    for (let k = from; k <= to; k++) total += a[k] * b[n - k]
    out[n] = total
  }
  return out
}
 
/**
 * Where output `i` of `np.convolve(x, kernel, mode='same')` is centred: it is
 * `full[i + centre]`, so it reads `x[i + centre − (L − 1) … i + centre]`.
 *
 * numpy swaps its arguments when the kernel is the longer of the two and centres
 * the `'same'` window on *that*, so on a sequence shorter than the kernel the
 * centre is the sequence's rather than the kernel's. yapc then writes all
 * `L` values into a sequence `length` bases long, and pyBigWig keeps the first
 * `length` of them — the alignment below.
 */
export function convolutionCentre(kernel: YapcKernel, length: number): number {
  return (Math.min(length, kernel.values.length) - 1) >> 1
}
 
/** How far, each side of a position, the convolution reads. */
export function convolutionReach(kernel: YapcKernel, length: number): { left: number; right: number } {
  const centre = convolutionCentre(kernel, length)
  return { left: kernel.values.length - 1 - centre, right: centre }
}
 
// ---------------------------------------------------------------------------
// Runs: a track as it arrives
// ---------------------------------------------------------------------------
 
/**
 * A stretch of one sequence as abutting constant pieces, `NaN` where there is no
 * value.
 *
 * The shape a bigWig *is* — `bedGraph`-like intervals — and the shape the
 * convolution below is fastest on. Expanding it to one value per base first
 * would cost 12 MB per megabase and change nothing.
 */
export interface Runs {
  /** 0-based, half-open, ascending and abutting. */
  readonly starts: Int32Array
  readonly ends: Int32Array
  readonly values: Float64Array
  readonly length: number
}
 
/**
 * The pieces of `[from, to)` a track's intervals describe, with every base they
 * do not cover as `NaN`.
 *
 * `NaN` rather than zero because that is what `pyBigWig.values` returns for a
 * base with no entry, and yapc never replaces it: the mean over tracks is `NaN`
 * wherever one of them is, and the convolution carries it `reach` bases each
 * way, where nothing can be concave. A lane with a hole in it has no peaks in
 * the hole.
 *
 * Intervals are clipped to the span and to each other: a bedGraph that repeats a
 * base is read for its first interval there, which is what a bigWig built from
 * it would hold. Abutting intervals of one value are one piece — a binned bigWig
 * stores a run of equal bins bin by bin — so a piece is a stretch of constant
 * signal, which is what the flat test in `convolveSame` asks about.
 */
export function runsOver(
  starts: ArrayLike<number>,
  ends: ArrayLike<number>,
  values: ArrayLike<number>,
  from: number,
  to: number,
): Runs {
  const outStarts: number[] = []
  const outEnds: number[] = []
  const outValues: number[] = []
  const piece = (start: number, end: number, value: number): void => {
    const last = outValues.length - 1
    if (last >= 0 && outEnds[last] === start && sameValue(outValues[last], value)) {
      outEnds[last] = end
      return
    }
    outStarts.push(start)
    outEnds.push(end)
    outValues.push(value)
  }
  let at = from
  for (let i = 0; i < starts.length && at < to; i++) {
    const start = Math.max(starts[i], at)
    const end = Math.min(ends[i], to)
    if (end <= start) continue
    if (start > at) piece(at, start, Number.NaN)
    piece(start, end, values[i])
    at = end
  }
  if (at < to) piece(at, to, Number.NaN)
  return {
    starts: Int32Array.from(outStarts),
    ends: Int32Array.from(outEnds),
    values: Float64Array.from(outValues),
    length: outStarts.length,
  }
}
 
/**
 * `[from, to)` as one piece of zeros: a track that does not have the sequence.
 *
 * yapc's `coverage()` reads such a track as zeros — `RuntimeError suggests
 * empty chromosome; returning flat signal` — so it pulls the pooled mean down
 * rather than blanking it, and its own scores there are `-inf`.
 */
export function zeroRuns(from: number, to: number): Runs {
  return {
    starts: Int32Array.of(from),
    ends: Int32Array.of(to),
    values: Float64Array.of(0),
    length: 1,
  }
}
 
/**
 * The mean of several tracks over `[from, to)`, as yapc's pooled coverage track
 * holds it after the round trip through a bigWig.
 *
 * `np.mean(l_val, axis=0)` adds the tracks in order and divides by their count,
 * and `addEntries` then stores it as float32: both are here, in that order, so
 * the pooled value is bit for bit the one yapc calls regions on. One track is
 * its own mean — and already float32 — so it passes through untouched.
 */
export function pooledRuns(tracks: readonly Runs[], from: number, to: number): Runs {
  if (tracks.length === 1) return tracks[0]
  const starts: number[] = []
  const ends: number[] = []
  const values: number[] = []
  const cursor = new Int32Array(tracks.length)
  let at = from
  while (at < to) {
    let next = to
    let total = 0
    for (let t = 0; t < tracks.length; t++) {
      const runs = tracks[t]
      while (cursor[t] < runs.length && runs.ends[cursor[t]] <= at) cursor[t]++
      const r = cursor[t]
      // Every track covers the whole span by construction (`runsOver` fills its
      // gaps), so a missing piece is a caller's mistake rather than data.
      if (r >= runs.length || runs.starts[r] > at) {
        throw new Error(`pooledRuns: a track does not cover ${at}`)
      }
      total += runs.values[r]
      if (runs.ends[r] < next) next = runs.ends[r]
    }
    const value = Math.fround(total / tracks.length)
    const last = values.length - 1
    if (last >= 0 && sameValue(values[last], value)) ends[last] = next
    else {
      starts.push(at)
      ends.push(next)
      values.push(value)
    }
    at = next
  }
  return {
    starts: Int32Array.from(starts),
    ends: Int32Array.from(ends),
    values: Float64Array.from(values),
    length: starts.length,
  }
}
 
/** Equal as signal: the same number, or both missing. */
function sameValue(a: number, b: number): boolean {
  return a === b || (a !== a && b !== b)
}
 
// ---------------------------------------------------------------------------
// The convolution
// ---------------------------------------------------------------------------
 
/**
 * `np.convolve(x, kernel, mode='same')` at positions `[from, to)` of a sequence
 * `length` bases long, with `x` given as {@link Runs}.
 *
 * `runs` must cover `[from − left, to + right)` inside the sequence, where
 * `left` and `right` are {@link convolutionReach}; positions outside the sequence
 * are zeros, which is numpy's padding.
 *
 * **Run by run rather than base by base**, and it is the same sum: a piece of
 * constant value `v` over `[s, e)` contributes `v · Σ kernel` over the taps it
 * meets, and that sum is a difference of the kernel's running total. A 456-tap
 * kernel over a bigWig whose intervals average five bases is ninety pieces per
 * position instead of 456 products. What it is *not* is the same float64
 * rounding as a dot product, which numpy's own BLAS does not promise either; the
 * golden (§18.10) holds the two to where every region, summit and score agrees.
 *
 * Flat input stays what numpy makes of it: a window inside one piece is `v` times
 * the kernel's total, a few times −10⁻¹¹ · `v` in either, and a window of zeros is
 * exactly zero.
 * Any `NaN` in the window makes the output `NaN`, as it does in a dot product.
 *
 * **Piece by piece, each position's terms in the order they always were.** A
 * position's value is one term per piece its window meets, added from the
 * leftmost piece to the rightmost, and that order is what fixes its last place.
 * Summed a position at a time, every addition waits on the one before it — a
 * chain of a hundred dependent float64 adds per base on an ATAC-seq pair, which
 * was most of the time a genome-wide call took (§18.8). So the loop is turned
 * inside out: each piece, in order, adds its term to every position whose
 * window it meets ({@link addTerms}). Each position still receives its terms in
 * the same order, as the same products of the same differences, so the result
 * is bit for bit the one a position at a time gives; what changes is that
 * neighbouring positions no longer wait on one another.
 */
export function convolveSame(
  runs: Runs,
  kernel: YapcKernel,
  length: number,
  from: number,
  to: number,
  /**
   * Set to 1 at each position whose whole window lies inside one constant piece
   * of the signal — where the second derivative is the kernel's sum times a
   * constant, which is to say flat rather than curved. See {@link ConcaveScan}.
   */
  flat?: Uint8Array,
  /**
   * Where given — 1 or 0 at each position — only the positions marked 1 are
   * computed, and the rest are `NaN`. A replicate's own second derivative is
   * read only where the pooled one is concave — about half of a genome — so the
   * rest is never summed.
   */
  only?: Uint8Array,
): Float64Array {
  const taps = kernel.values.length
  const centre = convolutionCentre(kernel, length)
  const out = new Float64Array(Math.max(0, to - from))
  if (only === undefined) addTerms(runs, kernel, centre, from, to, out, from)
  else {
    // Stretch by stretch of 1s, the gaps between them `NaN`.
    let at = 0
    while (at < out.length) {
      const k = only.indexOf(1, at)
      const start = k < 0 || k > out.length ? out.length : k
      out.fill(Number.NaN, at, start)
      if (start === out.length) break
      const stop = only.indexOf(0, start)
      const end = stop < 0 || stop > out.length ? out.length : stop
      addTerms(runs, kernel, centre, from + start, from + end, out, from)
      at = end
    }
  }
  if (flat !== undefined) {
    // A window `[i + centre − (taps − 1), i + centre + 1)` inside one piece
    // `[S, E)`: the positions from `S − centre + taps − 1` to `E − centre − 1`.
    for (let r = 0; r < runs.length; r++) {
      const first = Math.max(from, runs.starts[r] - centre + taps - 1)
      const last = Math.min(to - 1, runs.ends[r] - centre - 1)
      if (first <= last) flat.fill(1, first - from, last - from + 1)
    }
  }
  return out
}
 
/**
 * Every piece's term, added to each position of `[p, q)` whose window it meets,
 * into `out` — which holds position `from` at index 0 and starts at zero.
 *
 * Position `i` reads the bases `[low, high)` below its **anchor** `a = i +
 * centre + 1`: `low = max(0, a − taps)` and `high = min(length, a)`. A piece
 * `[S, E)` meets it where `S < a < E + taps`, over `[max(S, low), min(E, high))`
 * — and since a piece lies inside the sequence, the clamps at its two ends never
 * decide that overlap: it starts at `S` while `a ≤ S + taps`, at `a − taps` after,
 * and ends at `a` while `a < E`, at `E` after. So the anchors a piece reaches
 * fall in four stretches, each one formula:
 *
 * 1. `S < a < E`, `a ≤ S + taps`: the window's right end inside the piece;
 * 2. `E ≤ a ≤ S + taps`: the whole piece inside the window;
 * 3. `S + taps < a < E`: the whole window inside the piece;
 * 4. `a ≥ E`, `a > S + taps`: the window's left end inside the piece.
 *
 * The term is the one the overlap `[s, e)` always gave: `v · K[a − 1 − s]` for a
 * single base, `v · (C[a − s] − C[a − e])` otherwise, with `K` the kernel and `C`
 * its running total. One base is one tap, taken as it is: a difference of
 * running totals is accurate to their size rather than to the tap's, and a
 * bigWig at base resolution is mostly pieces one or two bases long. A single
 * base is where stretch 1 begins, where stretch 4 ends, and the whole of
 * stretch 2 for a piece one base long.
 */
function addTerms(
  runs: Runs,
  kernel: YapcKernel,
  centre: number,
  p: number,
  q: number,
  out: Float64Array,
  from: number,
): void {
  const taps = kernel.values.length
  const K = kernel.values
  const C = kernel.cumulative
  const starts = runs.starts
  const ends = runs.ends
  const level = runs.values
  // Anchors `[first, last]`, and where anchor `a` is in `out`: `a + shift`.
  const first = p + centre + 1
  const last = q + centre
  const shift = -(centre + 1) - from
  // The first piece that reaches `first`: `E + taps − 1 ≥ first`.
  let r = 0
  let hi = runs.length
  while (r < hi) {
    const mid = (r + hi) >>> 1
    if (ends[mid] > first - taps) hi = mid
    else r = mid + 1
  }
  const C0 = C[0]
  const sum = C[taps]
  for (; r < runs.length; r++) {
    const S = starts[r]
    if (S + 1 > last) break
    const E = ends[r]
    const v = level[r]
    // 1 · `s = S`, `e = a`.
    let a0 = S + 1 > first ? S + 1 : first
    let a1 = E - 1 < S + taps ? E - 1 : S + taps
    if (a1 > last) a1 = last
    if (a0 <= a1) {
      let a = a0
      if (a === S + 1) {
        out[shift + a] += v * K[0]
        a++
      }
      for (let o = shift + a, j = a - S, end = shift + a1; o <= end; o++, j++) out[o] += v * (C[j] - C0)
    }
    // 2 · `s = S`, `e = E`.
    a0 = E > first ? E : first
    a1 = S + taps < last ? S + taps : last
    if (a0 <= a1) {
      if (E - S === 1) {
        for (let o = shift + a0, j = a0 - 1 - S, end = shift + a1; o <= end; o++, j++) out[o] += v * K[j]
      } else {
        for (let o = shift + a0, j = a0 - S, k = a0 - E, end = shift + a1; o <= end; o++, j++, k++) {
          out[o] += v * (C[j] - C[k])
        }
      }
    }
    // 3 · `s = a − taps`, `e = a`: one term throughout.
    a0 = S + taps + 1 > first ? S + taps + 1 : first
    a1 = E - 1 < last ? E - 1 : last
    if (a0 <= a1) {
      const term = v * (sum - C0)
      for (let o = shift + a0, end = shift + a1; o <= end; o++) out[o] += term
    }
    // 4 · `s = a − taps`, `e = E`, one base wide at the last anchor.
    a0 = E > S + taps + 1 ? E : S + taps + 1
    if (a0 < first) a0 = first
    const single = E + taps - 1
    a1 = single < last ? single : last
    if (a0 <= a1) {
      const end = shift + (a1 === single ? a1 - 1 : a1)
      for (let o = shift + a0, k = a0 - E; o <= end; o++, k++) out[o] += v * (sum - C[k])
      if (a1 === single) out[shift + single] += v * K[taps - 1]
    }
  }
}
 
// ---------------------------------------------------------------------------
// Concave regions
// ---------------------------------------------------------------------------
 
/** Every concave region of one sequence, with each track's score for it. */
export interface ConcaveRegions {
  readonly contig: string
  readonly starts: Int32Array
  readonly ends: Int32Array
  /** yapc's `mode`: the most concave position, the middle one of a tie. */
  readonly modes: Int32Array
  /**
   * One array per track: `-np.mean(d2)` over the region, in float64 from that
   * track's own smoothed second derivative. `-Infinity` for a track that does
   * not have the sequence, which is what yapc's `scores()` yields for one.
   */
  readonly scores: Float64Array[]
  /**
   * 1 for a region the pooled signal is **flat** across somewhere inside — a
   * position whose whole kernel window is one constant piece. Not yapc's: see
   * {@link ConcaveScan}.
   */
  readonly flat: Uint8Array
}
 
/** The most values per track {@link ConcaveScan} keeps room for once a region is closed. */
const HELD_KEPT = 1 << 16
 
/**
 * yapc's test, `d2 < tol`, on the pooled second derivative as its bigWig holds
 * it — float32 — and so the one place a position is concave or not.
 */
export function isConcave(d2: number): boolean {
  return Math.fround(d2) < CONCAVE_TOLERANCE
}
 
/**
 * 1 at each position {@link isConcave} holds for: the only positions whose
 * per-track second derivatives {@link ConcaveScan.feed} reads, and so the only
 * ones `convolveSame` need compute them at (its `only`).
 */
export function concaveMask(pooled: Float64Array): Uint8Array {
  const out = new Uint8Array(pooled.length)
  for (let k = 0; k < pooled.length; k++) if (isConcave(pooled[k])) out[k] = 1
  return out
}
 
/**
 * `find_concave_regions_chrom` and `scores()`, fed one window at a time.
 *
 * yapc finds the rising and falling edges of `d2 < tol` over the whole
 * sequence, then patches the two ends: a region open at the first base gains a
 * start of 0, and one open at the last base gains an end of `length`. Fed in
 * windows, the edges are the same edges; the patches are applied in
 * {@link finish}, where it is known how the sequence ended.
 *
 * **No rising edge, no regions** — the early `return` that precedes both
 * patches, so a sequence that is concave from its first base and never becomes
 * concave again reports nothing, and nor does one concave throughout. Kept.
 * **A rising edge and no falling one** is where yapc stops with an `IndexError`
 * (`e[0]` of an empty array); here the one region runs to the end, which is
 * what both patches were written to say.
 *
 * **What is added is a flag, and nothing is removed for it.** A stretch of
 * constant signal passes yapc's test — its second derivative is the kernel's
 * float64 sum times the constant, zero or −10⁻¹¹ — so a run of explicit zeros
 * longer than the kernel is a concave region, scoring whatever the ringing at its
 * edges leaves (10⁻⁷, on a plateau of three kilobases). In yapc that is harmless:
 * IDR ranks it last in both replicates and drops it. A region is marked `flat`
 * where one of its positions had a window inside one constant piece
 * (`convolveSame`'s mask), which no peak has, so that a run with one track — which
 * yapc has no cut for — can leave it out without a threshold on its score.
 */
export class ConcaveScan {
  private readonly tracks: number
  private readonly absent: readonly boolean[]
  private starts: number[] = []
  private ends: number[] = []
  private modes: number[] = []
  private flats: number[] = []
  private readonly scores: number[][]
  private previous = false
  private rose = false
  private open = -1
  /** The most concave value so far in the open region, and where it is tied. */
  private best = Number.NEGATIVE_INFINITY
  private ties: number[] = []
  /** Each track's smoothed second derivative over the open region. */
  private held: Float64Array[]
  private heldLength = 0
  private openFlat = 0
  private position = 0
 
  constructor(
    readonly contig: string,
    readonly length: number,
    /** One flag per track: `true` for a track that does not have this sequence. */
    absent: readonly boolean[],
  ) {
    this.tracks = absent.length
    this.absent = absent
    this.scores = absent.map(() => [])
    this.held = absent.map(() => new Float64Array(1024))
  }
 
  /**
   * Positions `[from, from + pooled.length)`, which must follow the last ones fed.
   *
   * `pooled` is the pooled track's smoothed second derivative in float64; it is
   * rounded to float32 here, where yapc's round trip through `_d2smooth.bw`
   * would round it. `tracks` is each track's own, unrounded, over the same
   * positions — the same array as `pooled` when there is one track. `flat` is
   * `convolveSame`'s mask for the pooled track, where the caller wants it.
   */
  feed(
    from: number,
    pooled: Float64Array,
    tracks: readonly Float64Array[],
    flat?: Uint8Array,
  ): void {
    if (from !== this.position) {
      throw new Error(`ConcaveScan: fed ${from} after ${this.position}`)
    }
    for (let k = 0; k < pooled.length; k++) {
      const at = from + k
      const d2 = Math.fround(pooled[k])
      const concave = isConcave(d2)
      if (at === 0) {
        if (concave) this.begin(0)
      } else if (concave && !this.previous) {
        this.rose = true
        this.begin(at)
      } else if (!concave && this.previous) {
        this.close(at)
      }
      if (concave) {
        // `nanargmax_median(-d2y[i:j])`: the largest of `-d2` in float32, every
        // position that equals it, and the middle one of those at the end.
        const value = -d2
        if (value > this.best) {
          this.best = value
          this.ties = [at]
        } else if (value === this.best) {
          this.ties.push(at)
        }
        this.hold(tracks, k)
        if (flat !== undefined && flat[k] === 1) this.openFlat = 1
      }
      this.previous = concave
    }
    this.position = from + pooled.length
  }
 
  /** yapc's two end patches, and the early return before them. */
  finish(): ConcaveRegions {
    if (this.open >= 0) this.close(this.length)
    if (!this.rose) {
      this.starts = []
      this.ends = []
      this.modes = []
      this.flats = []
      for (const list of this.scores) list.length = 0
    }
    return {
      contig: this.contig,
      starts: Int32Array.from(this.starts),
      ends: Int32Array.from(this.ends),
      modes: Int32Array.from(this.modes),
      scores: this.scores.map((list) => Float64Array.from(list)),
      flat: Uint8Array.from(this.flats),
    }
  }
 
  private begin(at: number): void {
    this.open = at
    this.best = Number.NEGATIVE_INFINITY
    this.ties = []
    this.heldLength = 0
    this.openFlat = 0
    // A stretch of flat signal megabases long is one region, and grows these to
    // fit it — 32 MB a track over a mouse centromere's zeros. Let that go with the
    // region rather than hold it to the end of the sequence.
    if (this.held[0].length > HELD_KEPT) this.held = this.held.map(() => new Float64Array(1024))
  }
 
  private hold(tracks: readonly Float64Array[], k: number): void {
    if (this.heldLength === this.held[0].length) {
      this.held = this.held.map((values) => {
        const grown = new Float64Array(values.length * 2)
        grown.set(values)
        return grown
      })
    }
    for (let t = 0; t < this.tracks; t++) this.held[t][this.heldLength] = tracks[t][k]
    this.heldLength++
  }
 
  private close(at: number): void {
    this.starts.push(this.open)
    this.ends.push(at)
    this.flats.push(this.openFlat)
    this.modes.push(this.ties.length === 0 ? this.open : this.ties[this.ties.length >> 1])
    for (let t = 0; t < this.tracks; t++) {
      this.scores[t].push(
        this.absent[t] ? Number.NEGATIVE_INFINITY : -numpyMean(this.held[t], 0, this.heldLength),
      )
    }
    this.open = -1
  }
}
 
// ---------------------------------------------------------------------------
// From regions to peaks
// ---------------------------------------------------------------------------
 
/**
 * `df.rank(ascending=False)`: 1 for the highest score, ties sharing the mean of
 * the ranks they span (pandas' default `method='average'`).
 */
export function descendingRanks(scores: Float64Array): Float64Array {
  const n = scores.length
  const order = Array.from({ length: n }, (_, i) => i)
  order.sort((a, b) => scores[b] - scores[a] || a - b)
  const out = new Float64Array(n)
  let i = 0
  while (i < n) {
    let j = i + 1
    while (j < n && scores[order[j]] === scores[order[i]]) j++
    const rank = (i + 1 + j) / 2
    for (let k = i; k < j; k++) out[order[k]] = rank
    i = j
  }
  return out
}
 
/**
 * The regions yapc hands IDR: every track ranks them, each keeps its best rank,
 * and the `limit` best go through (`--truncate-idr-input`).
 *
 * Returned as indices in **yapc's IDR order** — sorted by sequence name, then
 * start, then end — because that is the order `fit_model_and_calc_local_idr`
 * adds its likelihoods in, and a port is held to the last place of those sums.
 *
 * A tie in best rank at the cut is broken by position. yapc sorts with pandas'
 * default `quicksort`, which leaves tied rows wherever numpy's introsort put
 * them; there is no order of theirs to reproduce, and position is one it could
 * have produced.
 */
export function strongestRegions(
  contigs: readonly string[],
  starts: Int32Array,
  ends: Int32Array,
  scores: readonly Float64Array[],
  limit: number,
): Int32Array {
  const n = starts.length
  const best = new Float64Array(n).fill(Number.POSITIVE_INFINITY)
  for (const column of scores) {
    const ranks = descendingRanks(column)
    for (let i = 0; i < n; i++) if (ranks[i] < best[i]) best[i] = ranks[i]
  }
  const byPosition = (a: number, b: number): number =>
    contigs[a] < contigs[b] ? -1 : contigs[a] > contigs[b] ? 1 : starts[a] - starts[b] || ends[a] - ends[b]
  const order = Array.from({ length: n }, (_, i) => i)
  order.sort((a, b) => best[a] - best[b] || byPosition(a, b))
  const kept = order.slice(0, Math.max(0, Math.min(n, limit)))
  kept.sort(byPosition)
  return Int32Array.from(kept)
}
 
/**
 * {@link strongestRegions}, fed one region at a time, holding only the regions
 * that can still be among the strongest.
 *
 * yapc ranks every candidate of the genome in each track before it cuts to the
 * strongest `limit`, and this did too, holding every one of them to the end —
 * 5.6 million on a mouse ATAC-seq pair, most of a gigabyte (§18.8) — for the
 * hundred thousand it kept. Far fewer decide the cut:
 *
 * - each track's `limit` highest scores, its ties at the cut included, are some
 *   `m ≥ limit` regions ranked `m` or better, so the best rank `B` of the last
 *   region kept is at most `m`;
 * - a region outside them ranks `m + 1` or worse in that track, whether ranked
 *   among every region or among those held, since every region above it is held.
 *
 * So a region with a best rank of `B` or better has it in a track whose top it is
 * in, where its rank among those held is its rank among all, and is worse than
 * `B` nowhere it ranks lower; and a region whose best rank is worse than `B` is
 * worse than `B` among those held too. {@link strongestRegions} over the regions
 * held therefore keeps the same regions, in the same order. A region is let go
 * when its score in every track is below that track's `limit`-th highest so far,
 * a threshold that only rises, so nothing let go could have come back.
 *
 * **Except past a `NaN` score**, which only a track holding an infinite value can
 * give: ranked by a comparison that is false both ways, it leaves an order that
 * depends on every region there is, and no subset reproduces it. The first one
 * stops the pruning, and {@link finish} says whether anything had been let go
 * before it ({@link StrongestRegionsHeld.exact}), in which case the caller has to
 * run again holding everything (`prune: false`).
 */
export class StrongestRegions {
  private size = 0
  private contigs: string[] = []
  private starts = new Int32Array(1024)
  private ends = new Int32Array(1024)
  private modes = new Int32Array(1024)
  private flats = new Uint8Array(1024)
  private scores: Float64Array[]
  /** Each track's `limit`-th highest score so far; −∞ until there are `limit`. */
  private readonly floor: Float64Array
  private seen = 0
  private dropped = 0
  private holdAll = false
  /** Track 0's score of each region let go, where it is positive and not flat. */
  private below: Float64Array | null
  private belowSize = 0
 
  constructor(
    private readonly tracks: number,
    private readonly limit: number,
    options: {
      /** `false` to hold every region, as {@link strongestRegions} is given them. */
      prune?: boolean
      /**
       * Keep track 0's curvature of each region let go — the one-track figure
       * draws every candidate, tested or not.
       */
      below?: boolean
    } = {},
  ) {
    this.scores = Array.from({ length: tracks }, () => new Float64Array(1024))
    this.floor = new Float64Array(tracks).fill(Number.NEGATIVE_INFINITY)
    if (options.prune === false) this.holdAll = true
    this.below = options.below === true ? new Float64Array(1024) : null
  }
 
  /** Region `i` of one sequence's regions. */
  add(regions: ConcaveRegions, i: number): void {
    this.seen++
    let keep = this.holdAll
    for (let t = 0; t < this.tracks; t++) {
      const score = regions.scores[t][i]
      if (score !== score && !this.holdAll) {
        this.holdAll = true
        keep = true
      }
      if (score >= this.floor[t]) keep = true
    }
    if (!keep) {
      this.drop(regions.scores[0][i], regions.flat[i])
      return
    }
    if (this.size === this.starts.length) this.grow()
    const at = this.size++
    this.contigs[at] = regions.contig
    this.starts[at] = regions.starts[i]
    this.ends[at] = regions.ends[i]
    this.modes[at] = regions.modes[i]
    this.flats[at] = regions.flat[i]
    for (let t = 0; t < this.tracks; t++) this.scores[t][at] = regions.scores[t][i]
    if (!this.holdAll && this.size >= 2 * this.tracks * this.limit + 1024) this.prune()
  }
 
  /** The regions held, and the strongest `limit` of them in yapc's IDR order. */
  finish(): StrongestRegionsHeld {
    if (!this.holdAll) this.prune()
    const n = this.size
    const held = {
      contig: this.contigs.slice(0, n),
      starts: this.starts.slice(0, n),
      ends: this.ends.slice(0, n),
      modes: this.modes.slice(0, n),
      flat: this.flats.slice(0, n),
      scores: this.scores.map((column) => column.slice(0, n)),
    }
    return {
      ...held,
      tested: strongestRegions(held.contig, held.starts, held.ends, held.scores, this.limit),
      below: this.below === null ? new Float64Array(0) : this.below.slice(0, this.belowSize),
      exact: !(this.holdAll && this.dropped > 0),
    }
  }
 
  /** Raise each track's threshold to its `limit`-th highest, and let go what is under all of them. */
  private prune(): void {
    if (this.seen < this.limit) return
    const n = this.size
    for (let t = 0; t < this.tracks; t++) {
      // Every region at or above the old threshold is held, and the `limit`
      // highest of all are among them, so the `limit`-th highest held is theirs.
      const sorted = this.scores[t].slice(0, n).sort()
      this.floor[t] = sorted[n - this.limit]
    }
    let kept = 0
    for (let i = 0; i < n; i++) {
      let keep = false
      for (let t = 0; t < this.tracks; t++) if (this.scores[t][i] >= this.floor[t]) keep = true
      if (!keep) {
        this.drop(this.scores[0][i], this.flats[i])
        continue
      }
      if (kept !== i) {
        this.contigs[kept] = this.contigs[i]
        this.starts[kept] = this.starts[i]
        this.ends[kept] = this.ends[i]
        this.modes[kept] = this.modes[i]
        this.flats[kept] = this.flats[i]
        for (let t = 0; t < this.tracks; t++) this.scores[t][kept] = this.scores[t][i]
      }
      kept++
    }
    this.size = kept
    this.contigs.length = kept
  }
 
  private drop(score: number, flat: number): void {
    this.dropped++
    if (this.below === null || !(score > 0) || flat === 1) return
    if (this.belowSize === this.below.length) {
      const grown = new Float64Array(this.below.length * 2)
      grown.set(this.below)
      this.below = grown
    }
    this.below[this.belowSize++] = score
  }
 
  private grow(): void {
    const twice = <T extends Int32Array | Uint8Array | Float64Array>(values: T): T => {
      const grown = new (values.constructor as new (n: number) => T)(values.length * 2)
      grown.set(values)
      return grown
    }
    this.starts = twice(this.starts)
    this.ends = twice(this.ends)
    this.modes = twice(this.modes)
    this.flats = twice(this.flats)
    this.scores = this.scores.map(twice)
  }
}
 
/** What {@link StrongestRegions} held, and what it found among them. */
export interface StrongestRegionsHeld {
  readonly contig: string[]
  readonly starts: Int32Array
  readonly ends: Int32Array
  readonly modes: Int32Array
  readonly flat: Uint8Array
  readonly scores: Float64Array[]
  /** {@link strongestRegions} of the regions held: indices into them, in yapc's IDR order. */
  readonly tested: Int32Array
  /** Track 0's positive curvature of every non-flat region let go, where asked for. */
  readonly below: Float64Array
  /** `false` where a `NaN` score came after a region was let go: run again, holding everything. */
  readonly exact: boolean
}
 
/**
 * yapc's final bounds: the concave region itself, or `halfWidth` bases each side
 * of its summit — `mode − h` to `mode + h + 1`.
 *
 * Clamped to the sequence, which yapc does not do: it writes the negative start
 * a summit near position 0 gives, and a BED line that no parser accepts is not a
 * peak anybody can use.
 */
export function peakBounds(
  start: number,
  end: number,
  mode: number,
  halfWidth: number,
  length: number,
): [number, number] {
  if (!(halfWidth > 0)) return [start, end]
  return [Math.max(0, mode - halfWidth), Math.min(length, mode + halfWidth + 1)]
}
 
/**
 * `-math.log(th, 10)`: the scaled global IDR a peak must reach at threshold `th`.
 *
 * `math.log(x, 10)` is `log(x) / log(10)`, not `log10(x)`, and at 0.001 the two
 * differ in the last place — `2.9999999999999996` against `3` — which decides
 * whether a peak whose IDR is exactly 10⁻³ is in or out. yapc's spelling is kept.
 */
export function scaledThreshold(threshold: number): number {
  return -(Math.log(threshold) / Math.log(10))
}
 
/** The IDR thresholds yapc writes a BED at, in its order. */
export const YAPC_IDR_THRESHOLDS: readonly number[] = [0.001, 0.005, 0.01, 0.05, 0.1, 0.2]