diff --git a/projects/sample/sample.py b/projects/sample/sample.py index 3cc8a9d7..1a15bd76 100644 --- a/projects/sample/sample.py +++ b/projects/sample/sample.py @@ -64,9 +64,10 @@ # project modules from rt-cloud sys.path.append(rootPath) # import project modules from rt-cloud -from rtCommon.utils import loadConfigFile, stringPartialFormat +from rtCommon.utils import loadConfigFile, stringPartialFormat, calcAvgRoundTripTime from rtCommon.clientInterface import ClientInterface from rtCommon.imageHandling import readRetryDicomFromDataInterface, convertDicomImgToNifti +from rtCommon.imageHandling import dicomTimeToNextTr # obtain the full path for the configuration toml file defaultConfig = os.path.join(currPath, 'conf/sample.toml') @@ -93,7 +94,14 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface): None. """ subjInterface.setMessage("Preparing Run ...") - time.sleep(1) + + # Time delay to add between retrieving pre-collected dicoms (for re-runs) + demoTimeDelay = 1 + + # get round trip time to dataInterface computer + rttSec = calcAvgRoundTripTime(dataInterface.ping) + # get clockSkew between this computer and the dataInterface computer + clockSkew = dataInterface.getClockSkew(time.time(), rttSec) # variables we'll use throughout scanNum = cfg.scanNum[0] @@ -157,7 +165,8 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface): """ if verbose: print("• initalize a watch for the dicoms using 'initWatch'") - dataInterface.initWatch(cfg.dicomDir, dicomScanNamePattern, cfg.minExpectedDicomSize) + dataInterface.initWatch(cfg.dicomDir, dicomScanNamePattern, + cfg.minExpectedDicomSize, demoStep=demoTimeDelay) else: # use Stream functions """ @@ -172,7 +181,8 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface): """ streamId = dataInterface.initScannerStream(cfg.dicomDir, dicomScanNamePattern, - cfg.minExpectedDicomSize) + cfg.minExpectedDicomSize, + demoStep=demoTimeDelay) """ @@ -303,7 +313,16 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface): minAvg = 305 maxAvg = 315 feedback = (avg_niftiData - minAvg) / (maxAvg - minAvg) - subjInterface.setResult(runNum, int(this_TR), float(feedback), 1000) + # Get the seconds remaining before next TR starts, this can be passed to + # the setResult function to delay stimulus until that time + try: + secUntilNextTr = dicomTimeToNextTr(dicomData, clockSkew) + print(f"## Secs to next TR {secUntilNextTr}") + except Exception as err: + print(f'dicomTimeToNextTr error: {err}') + + setFeedbackDelay = 500 # milliseconds + subjInterface.setResult(runNum, int(this_TR), float(feedback), setFeedbackDelay) # Finally we will use use webInterface.plotDataPoint() to send the result # to the web browser to be plotted in the --Data Plots-- tab. @@ -317,14 +336,12 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface): # save the activations value info into a vector that can be saved later all_avg_activations[this_TR] = avg_niftiData - time.sleep(1) # create the full path filename of where we want to save the activation values vector. # we're going to save things as .txt and .mat files output_textFilename = '/tmp/cloud_directory/tmp/avg_activations.txt' output_matFilename = os.path.join('/tmp/cloud_directory/tmp/avg_activations.mat') - time.sleep(1) subjInterface.setMessage("End Run") responses = subjInterface.getAllResponses() keypresses = [response.get('key_pressed') for response in responses] diff --git a/rtCommon/bidsIncremental.py b/rtCommon/bidsIncremental.py index 3d85465a..d7921dc6 100644 --- a/rtCommon/bidsIncremental.py +++ b/rtCommon/bidsIncremental.py @@ -7,6 +7,7 @@ -----------------------------------------------------------------------------""" from copy import deepcopy +from datetime import datetime from operator import eq as opeq from typing import Any, Callable import json @@ -38,6 +39,7 @@ symmetricDictDifference, writeDataFrameToEvents, ) +from rtCommon.utils import getTimeToNextTR from rtCommon.errors import MissingMetadataError logger = logging.getLogger(__name__) @@ -562,6 +564,27 @@ def getDataDirPath(self) -> str: """ return bids_build_path(self._imgMetadata, BIDS_DIR_PATH_PATTERN) + def getAcquisitionTime(self) -> datetime.time: + """Returns the acquisition time as a datetime.time """ + acqTm = self.getMetadataField('AcquisitionTime') + dtm = datetime.strptime(acqTm, '%H%M%S.%f') + return dtm.time() + + def getRepetitionTime(self) -> float: + """Returns the TR repetition time in seconds""" + repTm = self.getMetadataField('RepetitionTime') + tr_ms = float(repTm) + return tr_ms + + def timeToNextTr(self, clockSkew, now=None) -> float: + """Based on acquisition time returns seconds to next TR start""" + acquisitionTime = self.getAcquisitionTime() + repetitionTime = self.getRepetitionTime() + if now is None: # now variable can be passed in for testing + now = datetime.now().time() + secToNextTr = getTimeToNextTR(acquisitionTime, repetitionTime, now, clockSkew) + return secToNextTr + def writeToDisk(self, datasetRoot: str, onlyData=False) -> None: """ Writes the incremental's data to a directory on disk. NOTE: The diff --git a/rtCommon/bidsInterface.py b/rtCommon/bidsInterface.py index 5e5cd4f2..a81741bd 100644 --- a/rtCommon/bidsInterface.py +++ b/rtCommon/bidsInterface.py @@ -11,6 +11,7 @@ """ import os import glob +import time import tempfile import nibabel as nib from rtCommon.remoteable import RemoteableExtensible @@ -35,13 +36,15 @@ class BidsInterface(RemoteableExtensible): If dataRemote=False, then the methods below will be invoked locally and the RemoteExtensible parent class is inoperable (i.e. does nothing). """ - def __init__(self, dataRemote=False, allowedDirs=[]): + def __init__(self, dataRemote=False, allowedDirs=[], scannerClockSkew=0): """ Args: dataRemote (bool): Set to true for a passthrough instance that will forward requests. Set to false for the actual instance running remotely allowedDirs (list): Only applicable for DicomToBidsStreams. Indicates the directories that Dicom files are allowed to be read from. + scannerClockSkew (float): number of seconds the scanner's clock is ahead of the + data server clock """ super().__init__(isRemote=dataRemote) if dataRemote is True: @@ -54,6 +57,7 @@ def __init__(self, dataRemote=False, allowedDirs=[]): self.streamMap = {} # Store the allowed directories to be used by the DicomToBidsStream class self.allowedDirs = allowedDirs + self.scannerClockSkew = scannerClockSkew def initDicomBidsStream(self, dicomDir, dicomFilePattern, dicomMinSize, **entities) -> int: @@ -138,6 +142,34 @@ def getNumVolumes(self, streamId) -> int: return stream.getNumVolumes() + def getClockSkew(self, callerClockTime: float, roundTripTime: float) -> float: + """ + Returns the clock skew between the caller's computer and the scanner clock. + This function is assumed to be running in the scanner room and have adjustments + to translate this server's clock to the scanner clock. + Value returned is in seconds. A positive number means the scanner clock + is ahead of the caller's clock. The caller should add the skew to their + localtime to get the time in the scanner's clock. + Args: + callerClockTime - current time (secs since epoch) of caller's clock + roundTripTime - measured RTT in seconds to remote caller + Returns: + Clockskew - seconds the scanner's clock is ahead of the caller's clock + """ + # Adjust the caller's clock forward by 1/2 round trip time + callerClockAdjToNow = callerClockTime + roundTripTime / 2.0 + now = time.time() + # calcluate the time this server's clock is ahead of the caller's clock + skew = now - callerClockAdjToNow + # add the time skew from this server to the scanner clock + totalSkew = skew + self.scannerClockSkew + return totalSkew + + def ping(self) -> float: + """Returns seconds since the epoch""" + return time.time() + + class DicomToBidsStream(): """ A class that watches for DICOM file creation in a specified directory and with diff --git a/rtCommon/dataInterface.py b/rtCommon/dataInterface.py index 40e84494..454bc45a 100644 --- a/rtCommon/dataInterface.py +++ b/rtCommon/dataInterface.py @@ -38,7 +38,8 @@ class DataInterface(RemoteableExtensible): If dataRemote=False, then the methods below will be invoked locally and the RemoteExtensible parent class is inoperable (i.e. does nothing). """ - def __init__(self, dataRemote :bool=False, allowedDirs :List[str]=None, allowedFileTypes :List[str]=None): + def __init__(self, dataRemote :bool=False, allowedDirs :List[str]=None, + allowedFileTypes :List[str]=None, scannerClockSkew :float=0): """ Args: dataRemote (bool): whether data will be served from the local instance or requests forwarded @@ -48,6 +49,8 @@ def __init__(self, dataRemote :bool=False, allowedDirs :List[str]=None, allowedF allowedFileTypes (list): list of file extensions, such as '.dcm', '.txt', for which file operations are permitted. No file operations will be done unless the file extension matches one on the list. + scannerClockSkew (float): number of seconds the scanner's clock is ahead of the + data server clock """ super().__init__(isRemote=dataRemote) if dataRemote is True: @@ -57,6 +60,7 @@ def __init__(self, dataRemote :bool=False, allowedDirs :List[str]=None, allowedF self.currentStreamId = 0 self.streamInfo = None self.allowedDirs = allowedDirs + self.scannerClockSkew = scannerClockSkew # Remove trailing slash from dir names if allowedDirs is not None: self.allowedDirs = [dir.rstrip('/') for dir in allowedDirs] @@ -290,6 +294,32 @@ def getAllowedFileTypes(self) -> List[str]: """Returns the list of file extensions which are allowed for read and write""" return self.allowedFileTypes + def getClockSkew(self, callerClockTime: float, roundTripTime: float) -> float: + """ + Returns the clock skew between the caller's computer and the scanner clock. + This function is assumed to be running in the scanner room and have adjustments + to translate this server's clock to the scanner clock. + Value returned is in seconds. A positive number means the scanner clock + is ahead of the caller's clock. The caller should add the skew to their + localtime to get the time in the scanner's clock. + Args: + callerClockTime - current time (secs since epoch) of caller's clock + roundTripTime - measured RTT in seconds to remote caller + Returns: + Clockskew - seconds the scanner's clock is ahead of the caller's clock + """ + # Adjust the caller's clock forward by 1/2 round trip time + callerClockAdjToNow = callerClockTime + roundTripTime / 2.0 + now = time.time() + # calcluate the time this server's clock is ahead of the caller's clock + skew = now - callerClockAdjToNow + # add the time skew from this server to the scanner clock + return skew + self.scannerClockSkew + + def ping(self) -> float: + """Returns seconds since the epoch""" + return time.time() + def _checkAllowedDirs(self, dir: str) -> bool: if self.allowedDirs is None or len(self.allowedDirs) == 0: raise ValidationError('DataInterface: no allowed directories are set') diff --git a/rtCommon/imageHandling.py b/rtCommon/imageHandling.py index 44b31a38..5f4cd391 100644 --- a/rtCommon/imageHandling.py +++ b/rtCommon/imageHandling.py @@ -17,7 +17,9 @@ import numpy as np # type: ignore import nibabel as nib import pydicom -from rtCommon.errors import StateError, ValidationError, InvocationError, RequestError +from datetime import datetime +from rtCommon.utils import getTimeToNextTR +from rtCommon.errors import StateError, ValidationError, InvocationError from nilearn.image import new_img_like with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) @@ -63,11 +65,12 @@ def getDicomFileName(cfg, scanNum, fileNum): return fullFileName +# Note: don't anonymize AcquisitionTime, needed to sync with TR attributesToAnonymize = [ 'PatientID', 'PatientAge', 'PatientBirthDate', 'PatientName', 'PatientSex', 'PatientSize', 'PatientWeight', 'PatientPosition', 'StudyDate', 'StudyTime', 'SeriesDate', 'SeriesTime', - 'AcquisitionDate', 'AcquisitionTime', 'ContentDate', 'ContentTime', + 'AcquisitionDate', 'ContentDate', 'ContentTime', 'InstanceCreationDate', 'InstanceCreationTime', 'PerformedProcedureStepStartDate', 'PerformedProcedureStepStartTime' ] @@ -197,6 +200,34 @@ def parseDicomVolume(dicomImg, sliceDim): return volume +def getDicomAcquisitionTime(dicomImg) -> datetime.time: + """ + Returns the acquisition time as a datetime.time + Note: day, month and year are not specified + """ + acqTm = dicomImg.get('AcquisitionTime', None) + if acqTm is None: + return None + dtm = datetime.strptime(acqTm, '%H%M%S.%f') + return dtm.time() + +def getDicomRepetitionTime(dicomImg) -> float: + """Returns the TR repetition time in seconds""" + repTm = dicomImg.get('RepetitionTime', None) + if repTm is None: + return None + tr_sec = float(repTm) / 1000 + return tr_sec + +def dicomTimeToNextTr(dicomImg, clockSkew, now=None): + """Based on Dicom header returns seconds to next TR start""" + acquisitionTime = getDicomAcquisitionTime(dicomImg) + repetitionTime = getDicomRepetitionTime(dicomImg) + if now is None: # now variable may be passed in for testing purposes + now = datetime.now().time() + secToNextTr = getTimeToNextTR(acquisitionTime, repetitionTime, now, clockSkew) + return secToNextTr + """----------------------------------------------------------------------------- The following functions are used to convert dicom files into nifti files, which diff --git a/rtCommon/scannerDataService.py b/rtCommon/scannerDataService.py index d530ffa1..bd6896ec 100644 --- a/rtCommon/scannerDataService.py +++ b/rtCommon/scannerDataService.py @@ -33,10 +33,16 @@ def __init__(self, args, webSocketChannelName='wsData'): webSocketChannelName: The websocket url extension used to connecy and communicate to the remote projectServer, e.g. 'wsData' would connect to 'ws://server:port/wsData' """ + if args.scannerClockSkew is None: + args.scannerClockSkew = 0 + self.dataInterface = DataInterface(dataRemote=False, allowedDirs=args.allowedDirs, - allowedFileTypes=args.allowedFileTypes) - self.bidsInterface = BidsInterface(dataRemote=False, allowedDirs=args.allowedDirs) + allowedFileTypes=args.allowedFileTypes, + scannerClockSkew=args.scannerClockSkew) + self.bidsInterface = BidsInterface(dataRemote=False, + allowedDirs=args.allowedDirs, + scannerClockSkew=args.scannerClockSkew) self.wsRemoteService = WsRemoteService(args, webSocketChannelName) self.wsRemoteService.addHandlerClass(DataInterface, self.dataInterface) @@ -57,6 +63,8 @@ def __init__(self, args, webSocketChannelName='wsData'): help="Allowed directories to server files from - comma separated list") parser.add_argument('-f', action="store", dest="allowedFileTypes", default=defaultAllowedTypes, help="Allowed file types - comma separated list") + parser.add_argument('--scannerClockSkew', default=0.0, type=float, + help="Seconds (float) that the scanner clock is ahead of the data server clock") args, _ = parser.parse_known_args(namespace=connectionArgs) if type(args.allowedDirs) is str: diff --git a/rtCommon/utils.py b/rtCommon/utils.py index 918dfb23..b4c75b89 100644 --- a/rtCommon/utils.py +++ b/rtCommon/utils.py @@ -13,10 +13,12 @@ import subprocess import pathlib import logging +from datetime import datetime, date +from datetime import time as dtime import numpy as np # type: ignore import scipy.io as sio # type: ignore from .structDict import MatlabStructDict, isStructuredArray, recurseCreateStructDict -from .errors import InvocationError +from .errors import InvocationError, ValidationError def parseMatlabStruct(top_struct) -> MatlabStructDict: @@ -293,6 +295,51 @@ def trimDictBytes(msg, trimSize=64): msg.pop(key, None) +def getTimeToNextTR(lastTrTime, trRepSec, nowTime, clockSkew) -> float: + """ + Returns seconds to next TR start time + Args: + lastTrTime - datetime.time of the start of last TR + trRepSec - repetition time in seconds between TRs + nowTime - current time as datetime.time struct + clockSkew - seconds that scanner clock is ahead of caller clock + Returns: + seconds to next TR start (as float) + """ + # now + clockSkew (gives time according to scanner clock) + # ((now + clockSkew) - lastTr) % trRep (gives how many secs into tr) + # trRepSec - (above) (is sec to next TR) + assert type(lastTrTime) == dtime + assert type(nowTime) == dtime + nowTsec = dtimeToSeconds(nowTime) + lastTrTsec = dtimeToSeconds(lastTrTime) + secSinceTr = ((nowTsec + clockSkew) - lastTrTsec) + if secSinceTr < 0: + # lastTrTsec should be less than now + raise ValidationError(f"lastTrTime later than current time: {secSinceTr}") + secSinceTr = secSinceTr % trRepSec # remove any multipes of TRs that have elapsed + secToNextTr = trRepSec - secSinceTr + assert secToNextTr < trRepSec + return secToNextTr + +def dtimeToSeconds(valTime) -> float: + """Given a datetime.time return seconds.fraction since day beginning""" + assert type(valTime) == dtime + tdelta = datetime.combine(date.min, valTime) - datetime.min + return tdelta.total_seconds() + +def calcAvgRoundTripTime(pingFunc): + """Returns average round trip time in seconds""" + numCalls = 10 + accRtt = 0 + for i in range(numCalls): + t1 = time.time() + pingFunc() + t2 = time.time() + rtt = t2-t1 + accRtt += rtt + return accRtt / 10 + ''' import inspect # type: ignore def xassert(bool_val, message): diff --git a/tests/backgroundTestServers.py b/tests/backgroundTestServers.py index 73d89c9b..fb87ceab 100644 --- a/tests/backgroundTestServers.py +++ b/tests/backgroundTestServers.py @@ -114,6 +114,7 @@ def startServers(self, 'username': 'test', 'password': 'test', 'test': True, + 'scannerClockSkew' : 1.23, }) isRunningEvent = multiprocessing.Event() self.dataProc = multiprocessing.Process(target=runDataService, args=(args, isRunningEvent)) diff --git a/tests/test_bidsIncremental.py b/tests/test_bidsIncremental.py index 1c11c88f..6ce0d6ff 100644 --- a/tests/test_bidsIncremental.py +++ b/tests/test_bidsIncremental.py @@ -1,7 +1,9 @@ from copy import deepcopy import logging +import math import os import pickle +from datetime import time as dtime from bids.layout import BIDSImageFile from bids.layout.writing import build_path as bids_build_path @@ -22,7 +24,6 @@ from rtCommon.bidsArchive import BidsArchive from rtCommon.errors import MissingMetadataError from tests.common import isValidBidsArchive - logger = logging.getLogger(__name__) @@ -432,3 +433,23 @@ def testSerialization(validBidsI, sample4DNifti1, imageMetadata, tmpdir): # Check there's no file mapping assert deserialized.image.file_map['image'].filename is None + + +def test_bidsTimeToTr(validBidsI): + # The validBidsI acquisition time is 12:47:56.327500 + bidsAcqTm = validBidsI.getAcquisitionTime() + assert bidsAcqTm == dtime(12, 47, 56, 327500) + + repTm = validBidsI.getRepetitionTime() + assert repTm == 1.5 + + # create a nowTm about a second ahead of the bidsI acquisition time + now = dtime(hour=12, minute=47, second=57, microsecond=500000) + clockSkew = 0.0 + secToTr = validBidsI.timeToNextTr(clockSkew, now=now) + assert math.isclose(secToTr, 0.3275) + + now = dtime(hour=12, minute=47, second=57, microsecond=500000) + clockSkew = 0.10 + secToTr = validBidsI.timeToNextTr(clockSkew, now=now) + assert math.isclose(secToTr, .2275) \ No newline at end of file diff --git a/tests/test_bidsInterface.py b/tests/test_bidsInterface.py index 0a7354f3..8a9c8600 100644 --- a/tests/test_bidsInterface.py +++ b/tests/test_bidsInterface.py @@ -1,11 +1,15 @@ import os +import time +import math import pytest +from numpy.core.numeric import isclose from rtCommon.bidsArchive import BidsArchive from rtCommon.bidsIncremental import BidsIncremental from rtCommon.imageHandling import convertDicomImgToNifti, readDicomFromFile from rtCommon.clientInterface import ClientInterface from rtCommon.bidsInterface import BidsInterface, tmpDownloadOpenNeuro from rtCommon.bidsCommon import getDicomMetadata +import rtCommon.utils as utils from tests.backgroundTestServers import BackgroundTestServers from tests.common import rtCloudPath, tmpDir @@ -105,6 +109,15 @@ def dicomStreamTest(bidsInterface): print(f"Dicom stream check: image {idx}") assert streamIncremental == localIncremental + # check clock skew function + rtt = utils.calcAvgRoundTripTime(bidsInterface.ping) + now = time.time() + skew = bidsInterface.getClockSkew(now, rtt) + if bidsInterface.isRunningRemote(): + assert math.isclose(skew, 1.23, abs_tol=0.05) is True + else: + assert math.isclose(skew, 0, abs_tol=0.05) is True + def openNeuroStreamTest(bidsInterface): dsAccessionNumber = 'ds002338' diff --git a/tests/test_dataInterface.py b/tests/test_dataInterface.py index f8facf89..3f08f46c 100644 --- a/tests/test_dataInterface.py +++ b/tests/test_dataInterface.py @@ -2,7 +2,9 @@ import os import copy import time +import math import shutil +import rtCommon.utils as utils from rtCommon.clientInterface import ClientInterface from rtCommon.dataInterface import DataInterface, uploadFilesToCloud, downloadFilesFromCloud from rtCommon.imageHandling import readDicomFromBuffer @@ -180,6 +182,15 @@ def runDataInterfaceMethodTests(dataInterface, dicomTestFilename): print(f"Stream seek check: image {i}") assert streamImage == directImage + # check clock skew function + rtt = utils.calcAvgRoundTripTime(dataInterface.ping) + now = time.time() + skew = dataInterface.getClockSkew(now, rtt) + if dataInterface.isRemote == True: + assert math.isclose(skew, 1.23, abs_tol=0.05) is True + else: + assert math.isclose(skew, 0, abs_tol=0.05) is True + def runRemoteFileValidationTests(dataInterface): # Tests for remote dataInterface assert dataInterface.isRunningRemote() == True diff --git a/tests/test_imageHandling.py b/tests/test_imageHandling.py index 4563d4f5..9c973739 100644 --- a/tests/test_imageHandling.py +++ b/tests/test_imageHandling.py @@ -1,9 +1,11 @@ import os +import math import tempfile from nibabel.nicom import dicomreaders import numpy as np import pytest +from datetime import time as dtime from rtCommon.dataInterface import DataInterface from rtCommon.errors import ValidationError @@ -76,3 +78,23 @@ def test_nifti(): assert niftiImg1.header == niftiImgFromDcm.header assert np.array_equal(np.array(niftiImg1.dataobj), np.array(niftiImgFromDcm.dataobj)) + + +def test_dicomTimeToTr(dicomImage): + # The dicomImage acquisition time is 12:47:56.327500 + dcmAcqTm = imgHandler.getDicomAcquisitionTime(dicomImage) + assert dcmAcqTm == dtime(12, 47, 56, 327500) + + repTm = imgHandler.getDicomRepetitionTime(dicomImage) + assert repTm == 1.5 + + # create a nowTm about a second ahead of the dicom acquisition time + now = dtime(hour=12, minute=47, second=57, microsecond=500000) + clockSkew = 0.0 + secToTr = imgHandler.dicomTimeToNextTr(dicomImage, clockSkew, now=now) + assert math.isclose(secToTr, 0.3275) + + now = dtime(hour=12, minute=47, second=57, microsecond=500000) + clockSkew = 0.1 + secToTr = imgHandler.dicomTimeToNextTr(dicomImage, clockSkew, now=now) + assert math.isclose(secToTr, 0.2275) is True diff --git a/tests/test_utils.py b/tests/test_utils.py index 1a304d96..3a8ecb7e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,7 +8,8 @@ import numpy as np # type: ignore import numpy from glob import iglob -from pathlib import Path +from datetime import time as dtime +from math import isclose import rtCommon.utils as utils # type: ignore import rtCommon.projectUtils as putils # type: ignore import rtCommon.validationUtils as vutils # type: ignore @@ -214,6 +215,23 @@ def test_npToPy(self): res = putils.npToPy(args) assert res == args_py +def test_timeToTr(): + lastTrTime = dtime(hour=5, minute=10, second=1, microsecond=120000) + nowTime = dtime(5, 10, 2, 300000) + timeDiff = (utils.dtimeToSeconds(nowTime) - utils.dtimeToSeconds(lastTrTime)) + trRep = 2.0 + clockSkew = 0.0 + secToNextTr = utils.getTimeToNextTR(lastTrTime, trRep, nowTime, clockSkew) + assert isclose(secToNextTr, (trRep - timeDiff)) + + lastTrTime = dtime(hour=5, minute=10, second=1, microsecond=120000) + nowTime = dtime(5, 20, 2, 300000) + clockSkew = 0.120 + timeDiff = utils.dtimeToSeconds(nowTime) + clockSkew - utils.dtimeToSeconds(lastTrTime) + trRep = 2.0 + timeDiff = timeDiff % trRep + msToNextTr = utils.getTimeToNextTR(lastTrTime, trRep, nowTime, clockSkew) + assert isclose(msToNextTr, (trRep - timeDiff)) if __name__ == "__main__": print("PYTEST MAIN:")