Skip to content

Address each instrument independently in CENTRAL CLIENT mode - #195

Merged
cboulay merged 10 commits into
masterfrom
fix/central-per-instrument-channel-window
Aug 3, 2026
Merged

Address each instrument independently in CENTRAL CLIENT mode#195
cboulay merged 10 commits into
masterfrom
fix/central-per-instrument-channel-window

Conversation

@cboulay

@cboulay cboulay commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Makes every instrument independently addressable through Central: channel access, packet ids, and configuration writes. Two commits — channel windowing, then packet routing.

1. A HUB2 session read HUB1's configuration

Both sessions returned byte-identical config, and a HUB2 setter landed on HUB1's channel of the same number. Central's own Hub{N}- label prefixes made it unambiguous — a HUB2 session read 'Hub1-chan1'.

cbsdk_get_max_chans() returns the cbproto wire constant (cbMAXPROCS 1 → 284). It is a free function with no session parameter, so it cannot know which device is attached. Central 7.8 lays out 880 channels across four instruments, so the 284 ceiling cut Hub2's block short:

Hub1-  ch1..256    'Hub1-chan1' .. 'Hub1-chan256'
Hub2-  ch257..284  'Hub2-chan1' .. 'Hub2-chan28'   <- 28 of 256 visible

Hub2's channels 1–28 aliased onto what CereLink believed were Hub1's analog/Experiment I/O channels; its remaining 228 were rejected outright.

Why constants can't fix it

Central packs the connected instruments densely and renumbers when that set changes. With two hubs the NSP's ainp1 starts at 513 (bank 17); with one hub at 257 (bank 9); with none at 1 (bank 1). An earlier attempt using cbNUM_FE_CHANS as the I/O base was wrong, and made a HUB3 session alias onto the NSP's analog inputs — the same bug in a new place.

Fix

Discover the window at runtime from procinfo: chancount is authoritative per instrument, each instrument's channels are contiguous, and base = 1 + Σ chancount(j) for j < instrument. chanbase is device-local (always 1) and deliberately unused.

Sessions are addressed 1..getMaxChans() in both CENTRAL and STANDALONE. Packet chids are translated in dispatchBatch; an unresolved or identity window skips the loop, leaving the hot path unaffected. Resolution is lazy because procinfo lags session setup unpredictably — a Gemini NSP resolved on three opens out of four even with a one-second wait.

CentralChannelSpace and its five implementations were removed in favour of getProcInfoAt; the net result is smaller than the code it replaces.

2. Nothing but instrument 0 could be configured (#196)

sync() timed out for every instrument except 0, and since each auto_sync setter runs it first, all config writes failed for those devices.

Outbound CLIENT-mode packets never set cbpkt_header.instrument, so all went out as instrument 0; a non-zero-index session's reply is dropped by readReceiveBuffer's instrument filter, hence clean 5.0 s timeouts. The clock probe already stamped the field and carried a comment describing this exact mechanism — it was fixed once there and missed generally. Now stamped in sendPacket(), covering every CLIENT send.

That fixed sync() but writes still did not land. Only Central's chaninfo[] is indexed globally; Central forwards transmit-buffer packets verbatim (InstNetworkInstrument::Send, a plain UDP send with no per-instrument routing), so the device applies them in its own numbering. Translating the outbound id sent Hub2 a channel 257 it does not have. Validate against the window, send the id unchanged, route by instrument.

Verification

Two Gemini hubs + a Gemini NSP, Central 7.8 and STANDALONE:

Session max_chans ch1 sync() before → after write lands on itself
HUB1 256 Hub1-chan1 5 ok → 5 ok (21 ms) yes
HUB2 256 Hub2-chan1 0 ok / 5 fail → 5 ok (20 ms) yes
NSP 28 ainp1 0 ok / 5 fail → 5 ok (20 ms) yes
HUB3 (absent) 0 rejects every id

NSP bank assignments match the hardware: ainp1 bank 17, aout1 bank 18, dout4 bank 22.

Base is test/hardware-soak-harness because this depends on the multi-version Central adapters and was validated with that branch's soak harness.

Fixes #196

🤖 Generated with Claude Code

A HUB2 CENTRAL CLIENT session read HUB1's configuration. Both sessions
returned byte-identical config -- same labels, same group membership --
and a HUB2 setter landed on HUB1's channel of the same number. Central's
own Hub{N}- label prefixes proved it: a HUB2 session read 'Hub1-chan1'.

cbsdk_get_max_chans() returns the cbproto wire constant, where
cbMAXPROCS is 1, giving 284. It is a free function with no session, so
it cannot know which device is attached. Central 7.8 lays out 880
channels across four instruments, so the 284 ceiling cut Hub2's block
short: its channels 1-28 aliased onto what CereLink believed were Hub1's
analog/Experiment I/O channels, and its remaining 228 were unreachable.

The mapping cannot be computed from constants. Central packs the
connected instruments densely and renumbers them when that set changes:
with two hubs the NSP's ainp1 starts at 513, with one hub at 257, with
none at 1. An earlier attempt using cbNUM_FE_CHANS as the I/O base was
wrong and made a HUB3 session alias onto the NSP's analog inputs.

Discover the window at runtime from procinfo instead. chancount is
authoritative per instrument, each instrument's channels are contiguous,
and the base is the running sum of preceding chancounts. Sessions are
now addressed 1..getMaxChans() in both CENTRAL and STANDALONE: a Gemini
hub reports 256, the I/O-only Gemini NSP reports 28 with ainp1 at
channel 1, and a disconnected device reports 0 and rejects every id
rather than reading another instrument's data.

procinfo.chanbase is device-local (always 1) and is deliberately not
used as the global base.

Packet chids are translated in dispatchBatch, so callbacks see the same
ids as STANDALONE. An unresolved or identity window skips the loop, so
the hot path is unaffected. Window resolution is lazy because procinfo
lags session setup unpredictably -- a Gemini NSP resolved on three opens
out of four even with a one-second wait.

Verified against two Gemini hubs and a Gemini NSP under Central 7.8 and
in STANDALONE: hubs 256 channels, NSP 28 (ainp1 bank 17, aout1 bank 18,
dout4 bank 22), absent HUB3 0. Soak harness scores 80/80 and 81/81 on
the hubs in STANDALONE. ctest 482/486, the four failures pre-existing.

Known limitation, tracked separately: sync() still times out for any
instrument other than 0 under CENTRAL. HUB1 succeeds in ~20 ms while
HUB2 (instrument 1) and the NSP (instrument 3) both fail 5/5 at the 5 s
timeout, so every auto_sync setter fails for them. That is a distinct
defect in the transmit/response path, unaffected by this change, and
both devices sync normally in STANDALONE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cboulay and others added 2 commits August 3, 2026 00:34
sync() timed out for every instrument except 0 under CENTRAL, and since
each auto_sync setter runs sync() first and maps its failure to
CBSDK_RESULT_INTERNAL_ERROR, no device other than instrument 0 could be
configured through Central. HUB1 succeeded in ~20 ms while HUB2
(instrument 1) and the NSP (instrument 3) failed 5/5 at the 5 s timeout.
Both worked normally in STANDALONE, so it was neither the hardware nor
the device type.

Outbound CLIENT-mode packets never set cbpkt_header.instrument, so every
one was addressed to instrument 0. A non-zero-index session's reply is
then dropped by readReceiveBuffer's instrument filter and the wait times
out -- which is why the failures were clean 5.0 s timeouts rather than
errors. The clock probe already stamped the field and carried a comment
describing exactly this, but the general case was missed. Stamp it in
sendPacket() so every CLIENT send is covered at once: runlevel,
REQCONFIGALL, channel config, comments, digital output, file control.

That fixed sync() but writes still did not land. Only Central's
shared-memory chaninfo[] is indexed globally; Central forwards
transmit-buffer packets verbatim (InstNetwork drains straight into
Instrument::Send, a plain UDP send with no per-instrument routing), so
the device applies them in its own numbering -- Hub2's channels are
1..256 to Hub2 whatever Central calls them. Translating the outbound id
sent Hub2 a channel 257 it does not have. Validate against the window
but send the id unchanged; routing is by instrument.

Verified with two Gemini hubs and a Gemini NSP under Central 7.8: sync()
5/5 in ~20 ms on all three, and a distinct label written to each
device's channel 1 lands on that device and no other. ctest 482/486,
the four failures pre-existing.

Fixes #196

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two unrelated defects made every CCF save fail.

makeTempCcfPath() in both integration test files built the template as
"cerelink_..._XXXXXX.ccf" and passed it to _mktemp_s, which requires the
six X's to be the last six characters. It failed with EINVAL and, per
its contract, set the first character to '\0' -- so the tests asked the
writer to create an unusable path and read the resulting
CCFRESULT_ERR_OPENFAILEDWRITE as a CCF writer fault. Substitute into a
template that ends in the X's, then append the extension.

saveCCF() reached shared memory through getNativeConfigBuffer(), which
returns nullptr unless the layout is NATIVE, so a CENTRAL CLIENT had no
way to read its own configuration and every save failed. Fall back to
getLegacyConfigBuffer(), which translates Central's layout into the
native form.

That alone produced a file with the wrong contents. Central indexes
chaninfo[] globally across all instruments, and extractFromNativeConfig
copies the first cbMAXCHANS entries verbatim: with two hubs the result
held Hub1's 256 channels plus Hub2's first 28 sitting in the slots
reserved for analog/Experiment I/O, silently dropping everything past
284 of Central's 880. Loading such a file back would have written one
device's settings onto another's -- worse than the clean failure it
replaced.

A CCF now describes the device its session speaks for, matching the rest
of the API: this instrument's channels, in this instrument's numbering.
Everything else in the file (filters, sorting, LNC, waveforms, n-trodes,
sysinfo) is system-wide and is kept as extracted.

Verified under Central 7.8 with two Gemini hubs and a Gemini NSP: HUB1
saves 256 Hub1- channels, HUB2 saves 256 Hub2- channels, and the NSP
saves exactly its 28 I/O channels (16 ainp, 4 aout, 2 audio, 1 digin,
1 serial, 4 dout). ctest 484/486, both remaining failures pre-existing.

Fixes #197

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cboulay and others added 3 commits August 3, 2026 02:59
A Gemini NSP read through Central converted device timestamps 170-280 ms
into the future while reporting ~8 ms uncertainty. Hubs were fine, so it
looked device-specific; it is really a gap in where estimates come from.

The NSP transmits in ~8192-sample blocks and its probe replies are
latched to those boundaries: consecutive probes 600 ms apart returned
timestamps differing by exact multiples of 273.067 ms, and two probes
inside one block returned the identical timestamp. Every offset it
computes inherits that quantisation -- 187 ms of spread on 20 ms round
trips -- and no filtering recovers timing information the reply never
carried. Central's queue is not the cause; measured RTT is ~19 ms, which
bounds any asymmetry bias at ~10 ms.

STANDALONE already solves this: a session reads its peers' published
offsets from their shared memory and votes, and an NSP explicitly
borrows a hub. Under CENTRAL there is no peer CereLink process
publishing anything, so the vote list is always empty and the borrow
never fires. But Central's ring already carries every instrument's
packets -- a CENTRAL CLIENT was simply discarding them.

Add a generic pre-filter packet observer to ShmemSession. The instrument
filter is unchanged and what readReceiveBuffer returns is byte-identical;
the observer only sees packets already being walked to make that
decision. The shm layer stays ignorant of packet meaning -- the SDK
decides NPLAYREP is interesting -- so the demux path keeps a single
rule rather than growing a packet-type exception.

A CENTRAL session now probes every present instrument, keeps a per
instrument estimate, and applies the same vote/borrow rules STANDALONE
uses, so the two modes converge instead of drifting. setExternalOffset
still sanity-checks before adopting, so a peer that disagrees is
rejected -- no PTP assumption is hard-coded, which matters on legacy
analog hardware whose instruments are not commonly disciplined.

Also stop reporting rtt/2 as uncertainty when the samples disagree.
rtt/2 bounds the error only if they agree; the NSP advertised ~10 ms
confidence for a 187 ms-spread estimate, and a caller checking the
uncertainty had no way to tell. Use max(rtt/2, spread/2) over the
glitch-filtered set.

Verified under Central 7.8 with two Gemini hubs and a Gemini NSP: the
NSP's conversion moved from -169.8 ms to +8.4 ms, in line with the hubs
(+6.6, +10.3). Instrumentation shows the peer hub estimates at 0.29-0.41
ms spread against the NSP's own 187 ms, with the median electing a hub
value. ctest 485/486, the one failure pre-existing.

Two caveats. The uncertainty change landed after the devices went
offline, so its effect is unverified on hardware -- the 485/486 run
predates it, and only the 60 hardware-independent cbproto/cbshm tests
cover it. And CERELINK_CLOCK_DEBUG instrumentation in clock_sync.cpp is
deliberately retained for further testing; it is inert unless the
variable is set, and should be removed before merge.

Refs #198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A device whose probe replies are latched to its transmit blocks has no
usable probe estimate: a Gemini NSP accumulated 80 probes spanning
269.7 ms, one whole 8192-sample block, and the spread does not shrink
with more samples because the error is bounded by the block period
rather than random.

STANDALONE already handles this. DeviceSession feeds
addDataPacketSample, the estimator rejects the probes and uses the
data-packet floor, and on the same NSP that floor holds 320 samples
agreeing to within 1.4 ms. Under CENTRAL the fallback was never fed, so
a client had only the unusable probes. With hubs present the
cross-instrument consensus covers it, but an NSP alone under Central has
no peer to borrow from and nothing else to fall back to -- an unusual
configuration, though not an impossible one.

Feed the fallback once per batch from the CLIENT receive loop, mirroring
DeviceSession. No tick->ns conversion is needed because
readReceiveBuffer already normalises CLIENT timestamps. Selection order
makes it self-limiting: reliable probes still win, so a device with good
probes is unaffected and only one with unusable probes gains the floor.

Also stop reporting a flat 0.7 ms uncertainty for that floor. The floor
takes the least-delayed sample seen, so its error is whatever delay that
sample still carried; the disagreement among samples is the evidence for
it. Report max(ONE_WAY_DELAY_ESTIMATE_NS, spread/2), the same correction
already applied to the probe path. On healthy data this changes nothing
-- the NSP's 320 samples agree well inside the constant -- and it stops
the estimate from claiming sub-millisecond confidence when they scatter.

Correct the strategy comment that sent me looking for this in the wrong
place: the data branch is the NSP path for a *device session*. Only
DeviceSession feeds it, so it is unreachable for the CLIENT instance,
where a NATIVE client inherits its owner's committed offset and a
CENTRAL client derives one across instruments.

Verified in STANDALONE: NSP unc=700,000 ns delta +1.7 ms, HUB1
unc=102,670 ns delta +0.9 ms. 60/60 hardware-independent cbproto/cbshm
tests pass; the device-dependent suites were not run because the hubs
were offline.

Two things still unverified on hardware, both needing Central: the
CENTRAL half of this change (NSP alone, hubs disconnected), and the
max(rtt/2, spread/2) probe-path uncertainty from the previous commit,
which only differs when spread exceeds rtt and so needs the NSP reading
through Central.

Refs #198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CERELINK_CLOCK_DEBUG logging added while diagnosing #198 has served
its purpose: it showed the NSP's probe spread saturating at one
8192-sample block, the peer hub estimates at sub-millisecond spread
beside it, and data=0 proving the CLIENT fallback was never fed. All of
that is recorded in #198 and in the commits that fixed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cboulay
cboulay changed the base branch from test/hardware-soak-harness to master August 3, 2026 18:44
cboulay and others added 2 commits August 3, 2026 14:44
ClockSyncTest.BestProbeIsMaxOffset caught this: max(rtt/2, spread/2)
double-counts. Offsets legitimately spread when round trips differ -- a
slower probe yields a lower offset -- and picking the max offset already
compensates, so the whole spread is not evidence of error. The test's
three probes (RTT 500/100/300, offsets 9750/9950/9850) spread by exactly
what their round trips predict, and its expectation of rtt/2 = 50 is
right.

What rtt/2 cannot bound is spread the round trips do not explain: a
Gemini NSP latches its reply timestamps to 8192-sample transmit blocks
and produced 187 ms of spread on 20 ms round trips that varied by only
~10 ms. Charge the excess over (rtt_hi - rtt_lo) / 2 instead, which
leaves a well-behaved path reporting rtt/2 and still flags an estimate
whose samples disagree for reasons transit delay cannot account for.

I had run only the cbproto/cbshm subset after the previous version of
this change because the devices were offline, so the clock-sync unit
tests never ran locally. ClockSync is 53/53 now, and 391/392 across all
non-hardware suites (the one failure predates this branch).

Refs #198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cboulay
cboulay merged commit f4f79f1 into master Aug 3, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CENTRAL CLIENT: sync() and all config writes fail for any instrument other than 0

1 participant