Skip to content

fix(photon-lib): align C++/Python getTimestampSeconds on Java's capture-time semantic#2496

Open
JosephTLockwood wants to merge 7 commits into
PhotonVision:mainfrom
JosephTLockwood:fix/timestamp-port-alignment
Open

fix(photon-lib): align C++/Python getTimestampSeconds on Java's capture-time semantic#2496
JosephTLockwood wants to merge 7 commits into
PhotonVision:mainfrom
JosephTLockwood:fix/timestamp-port-alignment

Conversation

@JosephTLockwood

@JosephTLockwood JosephTLockwood commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

C++ PhotonCamera::GetAllUnreadResults writes value.time - latency into ntReceiveTimestamp, then GetTimestamp() subtracts latency again — every C++ vision timestamp is off by ~one full processing latency (~16 ms @ 60 fps). Java and Python land in roughly the right place by different routes.

This change has all three ports read metadata.captureTimestampMicros directly. Java already did; C++ and Python now match.

Why reading the field directly is correct

The coprocessor already populates the field with the value the consumer wants:

  1. Server (nt::Now) timebase. NTDataPublisher.java:200-211 adds the TimeSyncClient offset before publishing, with the in-line comment "Transform the metadata timestamps from the local wpi::nt::Now timebase to the Time Sync Server's timebase".
  2. Latency already subtracted. CVPipeline.java:82 stamps frame.timestampNanos (shutter-open time, set by the driver at LibcameraGpuFrameProvider.java:95-101) onto the result after process() returns. Direction confirmed by CVPipelineResult.java:112-114 where getLatencyMillis() returns (now - capture) — positive.

ntReceiveTimestamp keeps its documented meaning (receive time) for diagnostics; PhotonCamera just no longer pre-subtracts latency before storing it.

Test plan

  • Java compile + tests: green
  • C++ library build: clean
  • C++ GoogleTest: 60/60 passing
  • On-robot sanity check: confirm result.getTimestampSeconds() from a C++ poseest example is ~one frame closer to Timer.getFPGATimestamp() than before

Out of scope

Mid-exposure correction (anchoring captureTimestampMicros to the middle of the integration window instead of shutter-open) — see #2489.

…re-time semantic

C++ and Python compute getTimestampSeconds() as (receive - latency)
while Java reads metadata.captureTimestampMicros directly. The C++
path additionally pre-subtracts latency in PhotonCamera before
SetReceiveTimestamp, so GetTimestamp() subtracts latency a second
time — making every C++ vision timestamp off by ~one full processing
latency.

This change has all three ports read metadata.captureTimestampMicros
directly. Two properties of that field make this safe:

  1. It is in the Time Sync Server's nt::Now timebase (FPGA-relative
     on a real robot). At
     photon-core/src/main/java/org/photonvision/common/dataflow/networktables/NTDataPublisher.java:200-211
     the coprocessor reads getImageCaptureTimestampNanos(), adds the
     TimeSyncClient offset, and writes the result to the wire — with
     the in-line comment "Transform the metadata timestamps from the
     local wpi::nt::Now timebase to the Time Sync Server's timebase".

  2. Latency compensation is already baked in by the coprocessor.
     photon-core/src/main/java/org/photonvision/vision/pipeline/CVPipeline.java:82
     stamps frame.timestampNanos onto the result AFTER process()
     returns, so the value is the moment of capture, not publish.
     frame.timestampNanos itself is shutter-open time, set by the
     driver at
     photon-core/src/main/java/org/photonvision/vision/frame/provider/LibcameraGpuFrameProvider.java:95-101
     before any processing happens. The direction is confirmed by
     photon-core/src/main/java/org/photonvision/vision/pipeline/result/CVPipelineResult.java:112-114
     where getLatencyMillis() returns (wpiNanoTime() - capture) — a
     positive value, meaning capture precedes the latency reading.

ntReceiveTimestamp keeps its documented meaning (FPGA time bytes
arrived at robot code) for diagnostics. PhotonCamera no longer
pre-subtracts latency before SetReceiveTimestamp.

C++ tests previously relied on the (receive - 0_latency) projection
through GetTimestamp; updated to set captureTimestampMicros in
metadata directly, matching the Java test idiom.
@JosephTLockwood JosephTLockwood requested a review from a team as a code owner May 17, 2026 00:29
@github-actions github-actions Bot added the photonlib Things related to the PhotonVision library label May 17, 2026
…nit wrap

wpiformat (clang-format with the WPILib config) reflowed two
PhotonPipelineResult brace-initializers in this file. The CI Lint and
Format job rejected the prior wrap in both
ClosestToCameraHeightReturnsEmptyForNoFiducialTargets and
ConstrainedPnpOneTag; this commit applies the formatter's chosen split.

Pure whitespace; no semantic change.
// ntReceiveTimestamp records when the bytes arrived at robot code,
// independent of capture time. GetTimestamp() reads capture time
// directly from metadata.
result.SetReceiveTimestamp(wpi::units::microsecond_t(value.time));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No we shouldn't. I'll go through and clean it up. Probably a few more things as well that can be cleaned up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — audited the whole path and you're right, the field was write-only across C++ and Python (and Java never had the analog at all). Folded a cleanup into d323b97: deletes the field, the setter, the bespoke copy/move/operator= members that existed solely to propagate it across the proto-generated Base, the 14 dead test calls in PhotonPoseEstimatorTest, and the Python sim's matching +10 no-op offset. Net +2/−87.

…thon

Responds to mcm001's review on PR PhotonVision#2496: with getTimestampSeconds()
now reading metadata.captureTimestampMicros directly, the
ntReceiveTimestamp fallback has no remaining consumers — across the
entire repo, the field is written but never read. Java has never had
the analog at all. Deleting it brings the three ports into alignment
with Java as the reference.

C++ (photon-targeting + photon-lib):
- Remove ntReceiveTimestamp field, SetReceiveTimestamp setter, and
  the five bespoke copy/move/operator= members on PhotonPipelineResult
  whose sole purpose was propagating that field across the proto-
  generated Base. Defaulted special members are equivalent now that
  the only "extra" subclass field is gone.
- Drop both SetReceiveTimestamp call sites in PhotonCamera.cpp, the
  now-unused 'now' local in GetLatestResult, and the
  <wpi/system/RobotController.hpp> include.
- Strip 14 SetReceiveTimestamp lines from PhotonPoseEstimatorTest.cpp.
  Every test still sets metadata.captureTimestampMicros directly, so
  GetTimestamp() returns the expected value via the metadata path.
- Drop the CopyResult test that existed solely to verify the bespoke
  copy ctor propagated ntReceiveTimestamp; defaulted base copy is
  exercised by every other test in the suite.

Python (photon-lib/py):
- Remove ntReceiveTimestampMicros field from the PhotonPipelineResult
  dataclass and the three assignments (two in PhotonCamera, one in
  the sim). The sim's "+10" microsecond offset was decorative; the
  value was never observed.
- Drop the now-unused RobotController import in photonCamera.py and
  the _receiveLatencyMicros / receiveTimestampMicros() helper on
  PipelineTimestamps in test/testUtil.py.
The C++ port of CheckTimeSyncOrWarn writes the debounce comparison
backwards: it warns when LESS than WARN_DEBOUNCE_SEC has elapsed since
the last warning rather than MORE. Java has the same function with the
correct sense at PhotonCamera.java:319.

Practical effect: at 50 Hz NT packet rate with timeSinceLastPong > 5s,
every call sets prevTimeSyncWarnTime to "now", so the next call ~20 ms
later checks `now+0.02 < now+5` — always true — and fires another
warning. Result is a 50 Hz warning storm on the rio console rather
than the once-per-five-seconds debounce the surrounding code is
clearly trying to implement. In a less common path (packets pause for
>5 s, then resume with timesync still broken), the inverted check
becomes false on resume and the warning goes permanently silent until
timesync recovers and resets prevTimeSyncWarnTime to 0.

Fix is the one character that brings C++ in line with Java: flip `<`
to `>`. No semantic change to the alert (timesyncAlert.Set(true)) or
the reset branch.
@JosephTLockwood

Copy link
Copy Markdown
Contributor Author

Adjacent fix folded in as 0382956: the C++ port of CheckTimeSyncOrWarn had the debounce comparison inverted (< where Java has > at PhotonCamera.java:319). In practice this meant the "coprocessor not pong'ing" warning fired at the full NT packet rate (50 Hz on a typical robot) instead of the once-per-5s the surrounding code clearly intends — prevTimeSyncWarnTime was being bumped to now on every call, so the next check now+Δ < now+5 was always true. A less common variant (>5s packet gap, then resume with timesync still broken) makes it go permanently silent. One-character fix to bring C++ in line with Java.

@JosephTLockwood JosephTLockwood requested a review from mcm001 May 18, 2026 23:43
mcm001
mcm001 previously approved these changes May 18, 2026

@mcm001 mcm001 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm idk (pending below nit)

* with a timestamp.
* time base (nt::Now). The robot shall run a server, so this is FPGA-relative
* on a real robot. Reads metadata.captureTimestampMicros directly — latency
* compensation is already baked in by the coprocessor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I asked it to update comments. Been using it for comments and commit messages to try and speed things up. I'll try to be more careful.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear -- our current posture on LLM use is that it's allowed, just that if you LLM write code to please call it out in the MR description so I know how/what to review for. LLMs make different mistakes from people and that changes how and what I review for, and it's helpful to know.

In this case my review comment is more about the comment talking about implementation details and not (IMO) adding value. Not that my old comment was very helpful :(

@mcm001

mcm001 commented May 19, 2026

Copy link
Copy Markdown
Contributor

Can we roll #2497 into this MR as well?

@JosephTLockwood

Copy link
Copy Markdown
Contributor Author

Yeah I haven't had a chance to test it on a real system yet though.

…docs

The getCaptureTimestampMicros / getPublishTimestampMicros Javadocs
claim "coprocessor's time base", but NTDataPublisher applies the
TimeSyncClient offset to both values before publishing — the
on-wire value is in the Time Sync Server's nt::Now timebase, not
the coprocessor's local clock.

The field-level comment at PhotonPipelineMetadata.java:25-26 already
states the correct timebase ("wpi::nt::Now on the time sync server").
This commit aligns the getter Javadocs (which users see in IDE
autocomplete and online API docs) with that truth.

The Python per-field comment carried the same misleading prose;
updated to match.

No code or behavior change.
…stampMicros Javadoc

Spotless (google-java-format) rejected the prior wrap because the first
line fell under the 100-col target — its preferred split lands "time base"
on line 1 and "real robot." on line 2. CI Java Formatting job failed on
this file only.

Pure whitespace reflow; identical wording.
@JosephTLockwood

Copy link
Copy Markdown
Contributor Author

@
Rolled #2497 into this PR as 2202787 (docs) + 8c54b00 (formatter rewrap that CI on #2497 already validated). Cherry-picked verbatim — the Python field comment spot was untouched by the ntReceiveTimestamp cleanup, so it applied cleanly. Two files: PhotonPipelineMetadata.java getter Javadocs and photonPipelineResult.py dataclass comment. Docs-only.
@

@mcm001

mcm001 commented May 21, 2026

Copy link
Copy Markdown
Contributor

Failing pipeline hm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

photonlib Things related to the PhotonVision library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants