TDC calibration — the statistical code density test¶
The Kelpie ring-oscillator TDC does not have uniform bin widths. The raw
time_series code produced by unpack advances in steps whose
true duration varies code-to-code (differential non-linearity, DNL). A flat
code * 71.6 ps conversion is therefore only an average; timing-jitter and
coincidence work needs the exact per-code bin widths.
Those come from a statistical code density test.
Method¶
Under illumination that is uncorrelated with the free-running oscillator, the phase at which a photon is timestamped is uniformly random within one oscillator period. The probability of landing in a given code bin is then proportional to that bin's width. So, for every pixel independently:
- Histogram the valid TDC codes over all frames of all files →
counts[pixel, code]. - The width of code
kisfull_scale * counts[k] / total. - The calibrated time of code
kis the running integral up to the centre of its bin:
which is the minimum-quantisation-error estimator of the true time a code represents.
That LUT is exactly the time_lut (code → ps map) that
the coincidence pipeline and calc_diff accept.
Absolute scale¶
The density test supplies only the relative per-code widths. The absolute
scale is fixed by open_shutter_time_s — one period of the reference clock —
onto which the full populated code range is mapped.
A LUT belongs to one clock. The ring oscillator sets the code width
(~71.6 ps, core.timing.CODE_S) and the reference clock sets the full scale, so
the number of codes scales as 1/f:
| clock | full scale | codes (measured) |
|---|---|---|
| 50 MHz | 20 ns | 275 |
| 10 MHz | 100 ns | 1379 |
| 5 MHz | 200 ns | 2761 |
| 2 MHz | 500 ns | 6914 |
Calibrate once per clock. Every loader takes open_shutter_time_s and checks it
against the LUT's own full scale — a LUT is self-describing, its largest entry
is the scale it was built for (lut_full_scale_s) — and raises rather than
silently mis-converting. collect_and_save_luts tags the file name with the
window (TDC_LUT_SPAD0_S0_500ns.npy) so per-clock tables coexist in one folder.
The LUTs shipped in dapkel/params/calibration_data are 10 MHz only. Using
one on a 2 MHz run discards every code past 100 ns: 79 % of the singles, and
since a coincidence needs both arms, 96 % of the coincidences. That used to be
silent; it now raises at load time, and _structure_pixel_timestamps warns with
the discarded fraction if a LUT ever gets that far.
Note that the reference clock cannot go arbitrarily low. unpack builds the code
as (coarse - 1) * 8 + (8 - fine) from a 10-bit coarse field, so it saturates at
8184 codes ≈ 586 ns: below ~1.7 MHz the counter wraps and the timestamps are
meaningless. 2 MHz already uses 85 % of it.
Per-SPAD LUTs¶
The calibration data are recorded per micropixel — one SPADn / Sn tag per SPAD
of the 2×2 macropixel — so a separate (32, 32, n_codes) LUT is produced for
each of the four SPADs.
Because ORT does not record which of the four micropixels fired, the caller
must choose: name a specific SPAD when the optics illuminate a known one, or use
"average" to average the four.
Code range and overflow¶
The oscillator completes full_scale / 71.6 ps codes per measurement range, so
the code axis is sized from open_shutter_time_s via
core.timing.codes_for_full_scale — 10 % of headroom over the nominal count,
then trimmed to what is actually populated. Pass n_codes to override it.
Codes at or above the bound are out-of-range artefacts and are dropped with a
warning naming the fraction lost and the largest code seen. In practice that
warning means the code axis is too short for the clock in use, i.e. the wrong
open_shutter_time_s was given.
Pixels with no counts (dead in the calibration run) fall back to a nominal linear ramp over the same range, so downstream indexing never breaks.
Where the LUTs live¶
collect_and_save_luts writes TDC_LUT_<tag>.npy into processed/ (stage-1
data) with the QA plots going to results/tdc_calibration/. load_lut accepts
either the folder holding the LUTs directly or a data folder whose processed/
holds them.
load_board_lut instead reads the LUTs shipped with the package in
dapkel/params/calibration_data, selected by daughterboard / motherboard
number and resolved via importlib.resources so it works from a pip install.
API¶
tdc_calibration ¶
Per-pixel TDC calibration via the statistical code density test.
The ring-oscillator TDC has non-uniform bin widths (DNL), so a flat
code * 71.6 ps conversion is only an average. Under illumination uncorrelated
with the oscillator, each code's hit probability is proportional to its width,
which turns a per-pixel code histogram into a code -> ps lookup table:
LUT[k] = full_scale * (cumsum(counts)[k] - counts[k]/2) / total
One (32, 32, n_codes) LUT per SPAD; feed it to 'delta_t' as time_lut.
A LUT is specific to the reference clock it was taken with. The ring sets the
code width (~71.6 ps, see 'dapkel.core.timing.CODE_S') and the clock sets the
full scale, so the number of codes scales as 1/f: ~279 codes at 50 MHz, ~1397 at
10 MHz, ~2794 at 5 MHz, ~6985 at 2 MHz. Feeding a 10 MHz LUT to a 2 MHz run
therefore discards every code past 100 ns - 79 % of the singles and 96 % of the
coincidences - so every entry point here takes open_shutter_time_s and every
loader validates it against the LUT's own full scale (a LUT is self-describing:
its largest entry is its full scale). Calibrate once per clock.
Every time parameter is in seconds, per 'dapkel.core.timing'.
See docs/guide/tdc_calibration.md.
lut_full_scale_s ¶
Read a LUT's own full scale, in seconds.
A code -> ps LUT is self-describing: it was built by mapping the populated code range onto one TDC full scale, so its largest entry is that full scale. This is the trustworthy source for the accidental background's triangle half-base - more so than any clock setting recalled by hand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
time_lut
|
ndarray
|
A |
required |
Returns:
| Type | Description |
|---|---|
float
|
The full scale in seconds. |
Source code in src/dapkel/functions/tdc_calibration.py
compute_code_histogram ¶
compute_code_histogram(files: list[str], *, nframes: int, open_shutter_time_s: float | None = None, n_codes: int | None = None, label: str = '') -> ndarray
Accumulate the (32, 32, n_codes) per-pixel TDC-code histogram.
Unpacks each file's time_series (the raw TDC code) and, for every
pixel independently, counts how often each integer code occurs across
all frames. Only frames in which the pixel carried a valid timestamp
contribute - 'unpack' leaves non-fired slots at <= 0, so that is
time_series > 0.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
files
|
list[str]
|
Paths to the '.bin' files to unpack and accumulate. |
required |
nframes
|
int
|
Number of frames stored in each '.bin' file. |
required |
open_shutter_time_s
|
float | None
|
Open shutter time, i.e. one reference-clock period, in seconds. Sizes
the code axis via 'core.timing.codes_for_full_scale'. Required unless
|
None
|
n_codes
|
int | None
|
Number of code bins (0 .. n_codes-1), overriding the size derived from
|
None
|
label
|
str
|
Label used in the progress printout. The default is "". |
''
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The (32, 32, n_codes) int64 per-pixel code histogram. |
Raises:
| Type | Description |
|---|---|
ValueError
|
Raised when neither |
Warns:
| Type | Description |
|---|---|
UserWarning
|
Raised when any timestamp falls at or above |
Source code in src/dapkel/functions/tdc_calibration.py
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 | |
histogram_to_lut ¶
Convert a per-pixel code histogram into a code -> ps lookup table.
LUT[k] = full_scale * (cumsum(counts)[k] - counts[k]/2) / total.
Dead pixels (no counts) fall back to a linear ramp so downstream indexing
never divides by zero. Method: docs/guide/tdc_calibration.md.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
counts
|
ndarray
|
The (32, 32, n_codes) per-pixel code histogram from 'compute_code_histogram'. |
required |
open_shutter_time_s
|
float
|
Full scale, in seconds, onto which the populated code range is mapped: one reference-clock period. The density test only fixes the relative per-code widths, so this is what sets the absolute scale - and it is what 'lut_full_scale_s' reads back out of the finished table. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
The (32, 32, n_codes) float64 code -> ps lookup table. |
Source code in src/dapkel/functions/tdc_calibration.py
compute_tdc_lut ¶
compute_tdc_lut(folder: str, tag: str, *, nframes: int, open_shutter_time_s: float, n_codes: int | None = None, max_files: int | None = None) -> tuple[ndarray, ndarray]
Compute one SPAD's per-pixel code histogram and code -> ps LUT.
Accumulates the per-pixel code histogram for one SPAD tag, trims the empty top codes, and integrates it into the code -> ps LUT.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
folder
|
str
|
Path to the folder with the calibration '.bin' data files. |
required |
tag
|
str
|
Filename fragment selecting one SPAD's files (e.g. |
required |
nframes
|
int
|
Number of frames stored in each '.bin' file. |
required |
open_shutter_time_s
|
float
|
Open shutter time, i.e. one reference-clock period, in seconds. Sizes the code axis and sets the LUT's full scale. |
required |
n_codes
|
int | None
|
Number of code bins to allocate before trimming, overriding the size
derived from |
None
|
max_files
|
int | None
|
Process at most this many files (after natural sorting). The default is None (all files). |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
counts |
ndarray
|
The (32, 32, n_used) per-pixel code histogram (trimmed). |
lut |
ndarray
|
The (32, 32, n_used) code -> ps lookup table. |
Source code in src/dapkel/functions/tdc_calibration.py
plot_tdc_calibration ¶
plot_tdc_calibration(counts: ndarray, lut: ndarray, pixel: tuple[int, int] = (16, 16), *, open_shutter_time_s: float | None = None) -> Figure
QA plot of one pixel's density test: histogram, transfer curve, DNL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
counts
|
ndarray
|
The (32, 32, n_codes) per-pixel code histogram. |
required |
lut
|
ndarray
|
The (32, 32, n_codes) code -> ps lookup table. |
required |
pixel
|
tuple[int, int]
|
The |
(16, 16)
|
open_shutter_time_s
|
float | None
|
Full scale, in seconds, used to build the LUT, for the ideal-line reference. The default is None, which reads it back off the LUT with 'lut_full_scale_s'. |
None
|
Returns:
| Type | Description |
|---|---|
Figure
|
The generated three-panel figure. |
Source code in src/dapkel/functions/tdc_calibration.py
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 | |
collect_and_save_luts ¶
collect_and_save_luts(folder: str, *, nframes: int, open_shutter_time_s: float, tags: tuple[str, ...] = _SPAD_TAGS, n_codes: int | None = None, max_files: int | None = None, save_qa: bool = True, qa_pixel: tuple[int, int] = (16, 16)) -> dict[str, ndarray]
Compute and save one per-pixel code -> ps LUT per SPAD.
For every SPAD tag it unpacks the calibration files, builds the
(32, 32, n_used) code -> ps lookup table, and saves it as
TDC_LUT_<tag>_<window>.npy in the processed folder created inside
folder, where <window> is the open shutter time (e.g. 100ns).
A QA plot for one pixel is saved per SPAD when save_qa is True.
The window is part of the file name because a LUT is only valid for the reference clock it was taken with, so several must be able to sit side by side without one shadowing another.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
folder
|
str
|
Path to the folder with the calibration '.bin' data files. |
required |
nframes
|
int
|
Number of frames stored in each '.bin' file. |
required |
open_shutter_time_s
|
float
|
Open shutter time, i.e. one reference-clock period, in seconds. Sizes the code axis, sets the LUT's full scale, and tags the file name. |
required |
tags
|
tuple[str, ...]
|
SPAD filename fragments to process. The default is the four Kelpie
micropixel tags |
_SPAD_TAGS
|
n_codes
|
int | None
|
Number of code bins to allocate before trimming, overriding the size
derived from |
None
|
max_files
|
int | None
|
Process at most this many files per tag. The default is None. |
None
|
save_qa
|
bool
|
Save a per-SPAD QA plot (histogram, transfer curve, DNL). The default is True. |
True
|
qa_pixel
|
tuple[int, int]
|
Pixel shown in the QA plot. The default is (16, 16). |
(16, 16)
|
Returns:
| Type | Description |
|---|---|
dict[str, ndarray]
|
Mapping |
Source code in src/dapkel/functions/tdc_calibration.py
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 | |
load_lut ¶
load_lut(path: str, spad: int | str = 'average', *, open_shutter_time_s: float | None = None) -> ndarray
Load a per-pixel code -> ps LUT from a folder of '.npy' files.
Reads the *TDC_LUT_*.npy files in a folder (as written by
'collect_and_save_luts') and returns a single (32, 32, n_codes) lookup
table ready to pass to 'delta_t' as time_lut. For the LUTs shipped
with the package, use 'load_board_lut' instead.
Because the Kelpie ORT program stores one timestamp per macropixel and
does not record which of the four micropixels fired, the caller must
decide which SPAD's calibration to trust: pick a specific SPAD when the
optics illuminate a known micropixel, or use 'average' (the four
SPADs differ by ~1 LSB, so the mean is the best single estimate and the
residual spread is an irreducible systematic).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Either the folder holding the |
required |
spad
|
int | str
|
Which LUT to return when
|
'average'
|
open_shutter_time_s
|
float | None
|
Open shutter time, i.e. one reference-clock period, in seconds. When given, only LUTs tagged with that window are considered (falling back to untagged files), and the selected table's own full scale is checked against it. Pass this. A LUT is only valid for the clock it was taken with, and the failure mode without the check is silent: every code past the LUT's full scale is discarded downstream. The default is None (no filtering, no check). |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The (32, 32, n_codes) code -> ps lookup table. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
Raised when no |
ValueError
|
Raised when |
Source code in src/dapkel/functions/tdc_calibration.py
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 | |
load_board_lut ¶
load_board_lut(daughterboard_number: str, motherboard_number: str, spad: int | str = 'average', *, open_shutter_time_s: float | None = None) -> ndarray
Load the packaged per-pixel code -> ps LUT for a given board.
Resolves the calibration '.npy' files shipped inside the library
(dapkel/params/calibration_data) for the requested board and returns
a single (32, 32, n_codes) lookup table for 'delta_t'. Two naming forms are
accepted, the data directory being located via importlib.resources so
this works both from source and from a pip-installed wheel:
{daughterboard}_{motherboard}_{window}_TDC_LUT_SPAD{n}_S{n}.npy— per-clock, e.g.D0_M0_500ns_TDC_LUT_SPAD0_S0.npy; preferred whenopen_shutter_time_sis given;{daughterboard}_{motherboard}_TDC_LUT_SPAD{n}_S{n}.npy— untagged, which is what the LUTs shipped so far use. They were taken at 10 MHz (100 ns full scale), so they are not valid for another clock; theopen_shutter_time_scheck is what catches that.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
daughterboard_number
|
str
|
Daughterboard id, e.g. |
required |
motherboard_number
|
str
|
Motherboard id, e.g. |
required |
spad
|
int | str
|
|
'average'
|
open_shutter_time_s
|
float | None
|
Open shutter time, i.e. one reference-clock period, in seconds. Selects the per-clock LUTs when they exist and validates the full scale either way. Pass this: the shipped tables cover 100 ns only, and without the check a run on another clock silently loses every code past that. The default is None (no filtering, no check). |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The (32, 32, n_codes) code -> ps lookup table for the board. |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
Raised when no packaged LUTs exist for the requested board. |
ValueError
|
Raised when |
Source code in src/dapkel/functions/tdc_calibration.py
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 | |