Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions .github/workflows/test-symmetry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ jobs:
symDir = fullfile(pwd, "tests", "+ndi", "+symmetry");
matbox.installRequirements(symDir);

% Persist the fully-resolved search path (NDI dirs + the matbox
% dependencies just installed) so Stage 3 can reuse this one
% installation instead of running installRequirements a second
% time. A second install re-validates the already-installed repos
% and would fail any dependency that lacks a LICENSE/README.
savedPathFile = fullfile(getenv("GITHUB_WORKSPACE"), "ndi_symmetry_matlabpath.txt");
fid = fopen(savedPathFile, "w");
assert(fid > 0, "Could not write saved MATLAB path file.");
fprintf(fid, "%s", path());
fclose(fid);

import matlab.unittest.TestSuite
import matlab.unittest.TestRunner

Expand Down Expand Up @@ -123,17 +134,23 @@ jobs:
pytest tests/symmetry/read_artifacts/ -v --tb=short

# ── Stage 3: MATLAB readArtifacts ──────────────────────────────────
# Reuse the installation from Stage 1: restore the saved search path
# instead of re-running installRequirements. The dependency files
# persist on the runner between steps; only the path is lost when the
# fresh MATLAB session starts. This avoids a redundant install and the
# re-validation that install would perform on the existing repos.
- name: "Stage 3: MATLAB readArtifacts (reads Python artifacts)"
uses: matlab-actions/run-command@v2
with:
command: |
cd("NDI-matlab");
addpath(genpath("src"));
addpath(genpath("tests"));
addpath(genpath("tools"));

savedPathFile = fullfile(getenv("GITHUB_WORKSPACE"), "ndi_symmetry_matlabpath.txt");
assert(isfile(savedPathFile), ...
"Saved MATLAB path from Stage 1 is missing; cannot reuse install.");
path(fileread(savedPathFile));

symDir = fullfile(pwd, "tests", "+ndi", "+symmetry");
matbox.installRequirements(symDir);

import matlab.unittest.TestSuite
import matlab.unittest.TestRunner
Expand Down
10 changes: 7 additions & 3 deletions src/ndi/cloud/api/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,14 @@ def triggerStage(

@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def finalizeSession(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
"""POST /compute/{sessionId}/finalize"""
def advanceSession(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
"""POST /compute/{sessionId}/advance -- Advance a session to the next stage.

Advancing past the last stage finalizes the session; the cloud API exposes
no separate ``/finalize`` endpoint.
"""
return client.post(
"/compute/{sessionId}/finalize",
"/compute/{sessionId}/advance",
sessionId=session_id,
)

Expand Down
4 changes: 2 additions & 2 deletions src/ndi/cloud/api/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1261,8 +1261,8 @@ functions:
type_python: "dict[str, Any]"
decision_log: "Exact match."

- name: finalizeSession
matlab_path: "+ndi/+cloud/+api/+compute/finalizeSession.m"
- name: advanceSession
matlab_path: "+ndi/+cloud/+api/+compute/advanceSession.m"
matlab_last_sync_hash: "c5718fde"
python_path: "ndi/cloud/api/compute.py"
input_arguments:
Expand Down
133 changes: 133 additions & 0 deletions src/ndi/time/syncgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ def time_convert(
"""
from .timereference import ndi_time_timereference

# Same-referent fast path: when the input and output referents are the
# same object, the conversion can be resolved from the referent's own
# epochtable without consulting the syncgraph (no DAQ readers, no graph
# construction). This mirrors ndi.time.syncgraph/time_convert in
# NDI-matlab, which is the symmetry reference.
if self._is_same_referent(timeref_in.referent, referent_out) and hasattr(
timeref_in.referent, "epochtable"
):
return self._same_referent_convert(timeref_in, t_in, referent_out, clocktype_out)

# Get graph info
ginfo = self.graphinfo()

Expand Down Expand Up @@ -479,6 +489,129 @@ def time_convert(

return t_out, timeref_out, ""

@staticmethod
def _is_same_referent(referent_a: Any, referent_b: Any) -> bool:
"""Return True if two referents denote the same object.

Uses identity first, then falls back to the referent's own equality
(``==``) so that two live objects describing the same element compare
equal, mirroring the MATLAB ``timeref_in.referent == referent_out``
check.
"""
if referent_a is referent_b:
return True
try:
return bool(referent_a == referent_b)
except Exception:
return False

@staticmethod
def _normalize_epochtable(referent: Any) -> list[dict[str, Any]]:
"""Return a referent's epoch table as a list of entries.

``ndi.epoch.epochset.epochtable`` returns a ``(table, hash)`` tuple,
while lightweight referents may return the list directly; accept both.
"""
et = referent.epochtable()
if isinstance(et, tuple):
et = et[0]
return list(et) if et is not None else []

@staticmethod
def _clock_index(entry: dict[str, Any], clocktype: ndi_time_clocktype) -> int | None:
"""Find the index of CLOCKTYPE within an epoch table entry's clocks."""
clocks = entry.get("epoch_clock", [])
for k, clk in enumerate(clocks):
if clk == clocktype:
return k
return None

def _same_referent_convert(
self,
timeref_in: ndi_time_timereference,
t_in: float,
referent_out: Any,
clocktype_out: ndi_time_clocktype,
) -> tuple[float | None, ndi_time_timereference | None, str]:
"""Resolve a time conversion from a single referent's epoch table.

Counterpart of the same-referent branch in MATLAB
``ndi.time.syncgraph/time_convert``. All inputs share one referent, so
the epoch (and hence the linear rescaling between two clocks) is read
straight from the referent's epoch table.
"""
from .timereference import ndi_time_timereference

t0 = timeref_in.time or 0.0
et = self._normalize_epochtable(timeref_in.referent)

# Step 0: resolve the input epoch id.
in_epochid = timeref_in.epoch
if isinstance(in_epochid, (int, np.integer)):
# Numeric epoch -> id (1-indexed, matching MATLAB epochid()).
if 1 <= int(in_epochid) <= len(et):
in_epochid = et[int(in_epochid) - 1].get("epoch_id")
else:
return None, None, "ERROR:epochOutOfRange"

if in_epochid is None or in_epochid == "":
# Only resolvable for a global clock: find the epoch whose range for
# this clock contains the requested time.
if not clocktype_out.is_global() or not timeref_in.clocktype.is_global():
return None, None, "ERROR:noEpochForLocalClock"
target = t0 + t_in
in_epochid = None
for entry in et:
k = self._clock_index(entry, timeref_in.clocktype)
if k is None:
continue
lo, hi = entry["t0_t1"][k]
if lo <= target <= hi:
in_epochid = entry.get("epoch_id")
break
if in_epochid is None:
return None, None, "ERROR:noParentEpoch"

# Locate the matching epoch entry.
match = next((e for e in et if e.get("epoch_id") == in_epochid), None)
if match is None:
return None, None, "ERROR:missingEpoch"

# Same clock: identity conversion.
if timeref_in.clocktype == clocktype_out:
timeref_out = ndi_time_timereference(
referent=referent_out,
clocktype=clocktype_out,
epoch=in_epochid,
time=t0,
)
return t_in, timeref_out, ""

# Different clocks within the epoch: linear rescale between ranges.
j1 = self._clock_index(match, timeref_in.clocktype)
j2 = self._clock_index(match, clocktype_out)
if j1 is None:
return None, None, "ERROR:noInputClock"
if j2 is None:
return None, None, "ERROR:noOutputClock"

in_lo, in_hi = match["t0_t1"][j1]
out_lo, out_hi = match["t0_t1"][j2]
# Correct the input range for the reference time, then rescale (noclip).
corrected_lo, corrected_hi = in_lo - t0, in_hi - t0
span = corrected_hi - corrected_lo
if span == 0:
return None, None, "ERROR:degenerateRange"
t_out = out_lo + (t_in - corrected_lo) * (out_hi - out_lo) / span

timeref_out = ndi_time_timereference(
referent=referent_out,
clocktype=clocktype_out,
epoch=in_epochid,
time=0,
)
return t_out, timeref_out, ""

def _find_epoch_node(
self,
nodes: list[ndi_time_epochnode],
Expand Down
23 changes: 13 additions & 10 deletions tests/matlab_tests/test_cloud_compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,18 @@ def test_triggerStage_mocked(self):
result = triggerStage("session-abc-123", "stage-1", client=client)
assert result["status"] == "triggered"

def test_finalizeSession_mocked(self):
"""finalizeSession calls the correct endpoint (mocked)."""
from ndi.cloud.api.compute import finalizeSession
def test_advanceSession_mocked(self):
"""advanceSession calls the correct endpoint (mocked)."""
from ndi.cloud.api.compute import advanceSession

client = MagicMock()
client.post.return_value = {"status": "finalized"}
client.post.return_value = {"status": "advanced"}

result = finalizeSession("session-abc-123", client=client)
assert result["status"] == "finalized"
result = advanceSession("session-abc-123", client=client)
assert result["status"] == "advanced"
client.post.assert_called_once_with(
"/compute/{sessionId}/advance", sessionId="session-abc-123"
)

# ---- Live tests ----

Expand All @@ -149,11 +152,11 @@ def test_hello_world_flow_live(self):
3. List sessions (verify new session in list)
4. Abort session (cleanup)
5. triggerStage (expect possible error, just verify no crash)
6. finalizeSession (expect possible error, just verify no crash)
6. advanceSession (expect possible error, just verify no crash)
"""
from ndi.cloud.api.compute import (
abortSession,
finalizeSession,
advanceSession,
getSessionStatus,
listSessions,
startSession,
Expand Down Expand Up @@ -198,9 +201,9 @@ def test_hello_world_flow_live(self):
except Exception:
pass # Expected: session may be gone

# 6. finalizeSession — just verify no crash
# 6. advanceSession — just verify no crash
try:
finalizeSession(session_id, client=client)
advanceSession(session_id, client=client)
except Exception:
pass # Expected: session may be gone

Expand Down
Loading
Loading