Reading and processing NeXus choppers#
This guide shows how to extract the relevant data from a NeXus representation of a chopper and create a scippneutron.chopper.DiskChopper object.
We also demonstrate some utilities that Scippneutron provides to process and visualize the data inside NeXus choppers.
[1]:
import scipp as sc
NeXus chopper data#
Here, we use fake data which roughly represents what a real chopper loaded from NeXus looks like. ScippNeutron has a function for generating this data:
[2]:
from scippneutron.data import chopper_mockup
chopper = chopper_mockup()
chopper
[2]:
- beam_positionscippVariable()float64rad0.0
- scippDataGroup(time: 1)
- valuescippDataArray(time: 1)int64ns30500000
- positionscippVariable()vector3m[ 0. 0. 60.]
- radiusscippVariable()float64m0.35
- scippDataGroup(time: 180)
- valuescippDataArray(time: 180)float64Hz1.287e-05, 0.359, ..., 0.352, -4.904e-07
- scippDataGroup()
- valuescippVariable()float64Hz14.0
- slit_heightscippVariable()float64m0.1
- slit_edgesscippVariable(slit: 4)float64deg30.0, 160.0, 210.0, 280.0
- slitsint()2
- scippDataGroup(time: 2354)
- timescippVariable(time: 2354)datetime64ns2023-01-19T08:11:06.217830400, 2023-01-19T08:11:08.799053824, ..., 2023-01-19T08:14:39.174524416, 2023-01-19T08:14:40.640919296
We can see that there is some information about the slits and geometry of the chopper as well as some timing-related data. Take a look at the NXdisk_chopper documentation for an overview of the fields.
In this case, there already is a position. This typically needs to be computed first, see scippnexus.compute_positions.
Some fields are nested data groups which happens when a NeXus file contains NXlogs. We can extract the relevant arrays from them using extract_chopper_from_nexus:
[3]:
from scippneutron.chopper import extract_chopper_from_nexus
chopper = extract_chopper_from_nexus(chopper)
chopper
[3]:
- typescippneutronDiskChopperType()Chopper type single
- beam_positionscippVariable()float64rad0.0
- delayscippDataArray()int64ns30500000
- positionscippVariable()vector3m[ 0. 0. 60.]
- radiusscippVariable()float64m0.35
- rotation_speedscippDataArray(time: 180)float64Hz1.287e-05, 0.359, ..., 0.352, -4.904e-07
- rotation_speed_setpointscippVariable()float64Hz14.0
- slit_heightscippVariable()float64m0.1
- slit_edgesscippVariable(slit: 4)float64deg30.0, 160.0, 210.0, 280.0
- slitsint()2
- top_dead_centerscippVariable(time: 2354)datetime64ns2023-01-19T08:11:06.217830400, 2023-01-19T08:11:08.799053824, ..., 2023-01-19T08:14:39.174524416, 2023-01-19T08:14:40.640919296
Converting to a DiskChopper#
We can assemble all data into a scippneutron.chopper.DiskChopper object, which enables better exploration/visualization of the chopper properties.
[4]:
from scippneutron.chopper import DiskChopper
disk_chopper = DiskChopper.from_nexus(chopper)
disk_chopper
[4]:
- axle_positionscippVariable()vector3m[ 0. 0. 60.]
- frequencyscippVariable()float64Hz14.0
- beam_positionscippVariable()float64rad0.0
- phasescippVariable()float64rad2.6829201261656834
- slit_beginscippVariable(slit: 2)float64deg30.0, 210.0
- slit_endscippVariable(slit: 2)float64deg160.0, 280.0
- slit_heightscippVariable(slit: 2)float64m0.1, 0.1
- radiusscippVariable()float64m0.35
Note that the chopper phase \(\phi\) was derived from the rotation frequency \(f\) and the delay \(\delta_{t}\) using \(\phi = 2 \pi f \delta_{t}\).
With the DiskChopper, we can for example see at what times the chopper is open and closed:
[5]:
pulse_frequency = sc.scalar(14, unit="Hz")
print("Times open: ", disk_chopper.time_offset_open(pulse_frequency=pulse_frequency))
print("Times closed:", disk_chopper.time_offset_close(pulse_frequency=pulse_frequency))
Times open: <scipp.Variable> (slit: 4) float64 [s] [-0.00124603, -0.0250556, 0.0701825, 0.046373]
Times closed: <scipp.Variable> (slit: 4) float64 [s] [0.0245476, -0.0111667, 0.0959762, 0.0602619]
Inspecting time-dependent logs#
Some of the entries in the raw NeXus data group are time-dependent. In this section we take a closer look at these logs to gain more insights in how the chopper data is actually recorded.
Identifying in-phase regions#
Frame unwrapping and time-of-flight computation are only feasible when the choppers are in-phase with the neutron source pulses because, otherwise, the wavelength frames vary pulse-by-pulse.
To identify regions where a chopper is in-phase, we can inspect the rotation_speed which is the rotation frequency of the chopper.
[6]:
rotation_speed = chopper['rotation_speed']
rotation_speed.name = 'rotation_speed'
rotation_speed
[6]:
- time: 180
- time(time)datetime64ns2023-01-19T08:11:06.205830400, 2023-01-19T08:11:07.412763648, ..., 2023-01-19T08:14:41.039957504, 2023-01-19T08:14:42.246890752
Values:
array(['2023-01-19T08:11:06.205830400', '2023-01-19T08:11:07.412763648', '2023-01-19T08:11:08.619696896', '2023-01-19T08:11:09.826630400', '2023-01-19T08:11:11.033563648', '2023-01-19T08:11:12.240496896', '2023-01-19T08:11:13.447430144', '2023-01-19T08:11:14.654363392', '2023-01-19T08:11:15.861296896', '2023-01-19T08:11:17.068230144', '2023-01-19T08:11:18.275163392', '2023-01-19T08:11:19.482096640', '2023-01-19T08:11:20.689029888', '2023-01-19T08:11:21.895963392', '2023-01-19T08:11:23.102896640', '2023-01-19T08:11:24.309829888', '2023-01-19T08:11:25.516763136', '2023-01-19T08:11:26.723696384', '2023-01-19T08:11:27.930629888', '2023-01-19T08:11:29.137563136', '2023-01-19T08:11:30.344496384', '2023-01-19T08:11:31.551429632', '2023-01-19T08:11:32.758362880', '2023-01-19T08:11:33.965296128', '2023-01-19T08:11:35.172229632', '2023-01-19T08:11:36.379162880', '2023-01-19T08:11:37.586096128', '2023-01-19T08:11:38.793029376', '2023-01-19T08:11:39.999962624', '2023-01-19T08:11:41.206896128', '2023-01-19T08:11:42.413829376', '2023-01-19T08:11:43.620762624', '2023-01-19T08:11:44.827695872', '2023-01-19T08:11:46.034629120', '2023-01-19T08:11:47.241562624', '2023-01-19T08:11:48.448495872', '2023-01-19T08:11:49.655429120', '2023-01-19T08:11:50.862362368', '2023-01-19T08:11:52.069295616', '2023-01-19T08:11:53.276229120', '2023-01-19T08:11:54.483162368', '2023-01-19T08:11:55.690095616', '2023-01-19T08:11:56.897028864', '2023-01-19T08:11:58.103962112', '2023-01-19T08:11:59.310895616', '2023-01-19T08:12:00.517828864', '2023-01-19T08:12:01.724762112', '2023-01-19T08:12:02.931695360', '2023-01-19T08:12:04.138628608', '2023-01-19T08:12:05.345562112', '2023-01-19T08:12:06.552495360', '2023-01-19T08:12:07.759428608', '2023-01-19T08:12:08.966361856', '2023-01-19T08:12:10.173295104', '2023-01-19T08:12:11.380228608', '2023-01-19T08:12:12.587161856', '2023-01-19T08:12:13.794095104', '2023-01-19T08:12:15.001028352', '2023-01-19T08:12:16.207961600', '2023-01-19T08:12:17.414895104', '2023-01-19T08:12:18.621828352', '2023-01-19T08:12:19.828761600', '2023-01-19T08:12:21.035694848', '2023-01-19T08:12:22.242628096', '2023-01-19T08:12:23.449561600', '2023-01-19T08:12:24.656494848', '2023-01-19T08:12:25.863428096', '2023-01-19T08:12:27.070361344', '2023-01-19T08:12:28.277294592', '2023-01-19T08:12:29.484227840', '2023-01-19T08:12:30.691161344', '2023-01-19T08:12:31.898094592', '2023-01-19T08:12:33.105027840', '2023-01-19T08:12:34.311961088', '2023-01-19T08:12:35.518894336', '2023-01-19T08:12:36.725827840', '2023-01-19T08:12:37.932761088', '2023-01-19T08:12:39.139694336', '2023-01-19T08:12:40.346627584', '2023-01-19T08:12:41.553560832', '2023-01-19T08:12:42.760494336', '2023-01-19T08:12:43.967427584', '2023-01-19T08:12:45.174360832', '2023-01-19T08:12:46.381294080', '2023-01-19T08:12:47.588227328', '2023-01-19T08:12:48.795160832', '2023-01-19T08:12:50.002094080', '2023-01-19T08:12:51.209027328', '2023-01-19T08:12:52.415960576', '2023-01-19T08:12:53.622893824', '2023-01-19T08:12:54.829827328', '2023-01-19T08:12:56.036760576', '2023-01-19T08:12:57.243693824', '2023-01-19T08:12:58.450627072', '2023-01-19T08:12:59.657560320', '2023-01-19T08:13:00.864493824', '2023-01-19T08:13:02.071427072', '2023-01-19T08:13:03.278360320', '2023-01-19T08:13:04.485293568', '2023-01-19T08:13:05.692226816', '2023-01-19T08:13:06.899160320', '2023-01-19T08:13:08.106093568', '2023-01-19T08:13:09.313026816', '2023-01-19T08:13:10.519960064', '2023-01-19T08:13:11.726893312', '2023-01-19T08:13:12.933826816', '2023-01-19T08:13:14.140760064', '2023-01-19T08:13:15.347693312', '2023-01-19T08:13:16.554626560', '2023-01-19T08:13:17.761559808', '2023-01-19T08:13:18.968493312', '2023-01-19T08:13:20.175426560', '2023-01-19T08:13:21.382359808', '2023-01-19T08:13:22.589293056', '2023-01-19T08:13:23.796226304', '2023-01-19T08:13:25.003159552', '2023-01-19T08:13:26.210093056', '2023-01-19T08:13:27.417026304', '2023-01-19T08:13:28.623959552', '2023-01-19T08:13:29.830892800', '2023-01-19T08:13:31.037826048', '2023-01-19T08:13:32.244759552', '2023-01-19T08:13:33.451692800', '2023-01-19T08:13:34.658626048', '2023-01-19T08:13:35.865559296', '2023-01-19T08:13:37.072492544', '2023-01-19T08:13:38.279426048', '2023-01-19T08:13:39.486359296', '2023-01-19T08:13:40.693292544', '2023-01-19T08:13:41.900225792', '2023-01-19T08:13:43.107159040', '2023-01-19T08:13:44.314092544', '2023-01-19T08:13:45.521025792', '2023-01-19T08:13:46.727959040', '2023-01-19T08:13:47.934892288', '2023-01-19T08:13:49.141825536', '2023-01-19T08:13:50.348759040', '2023-01-19T08:13:51.555692288', '2023-01-19T08:13:52.762625536', '2023-01-19T08:13:53.969558784', '2023-01-19T08:13:55.176492032', '2023-01-19T08:13:56.383425536', '2023-01-19T08:13:57.590358784', '2023-01-19T08:13:58.797292032', '2023-01-19T08:14:00.004225280', '2023-01-19T08:14:01.211158528', '2023-01-19T08:14:02.418092032', '2023-01-19T08:14:03.625025280', '2023-01-19T08:14:04.831958528', '2023-01-19T08:14:06.038891776', '2023-01-19T08:14:07.245825024', '2023-01-19T08:14:08.452758528', '2023-01-19T08:14:09.659691776', '2023-01-19T08:14:10.866625024', '2023-01-19T08:14:12.073558272', '2023-01-19T08:14:13.280491520', '2023-01-19T08:14:14.487425024', '2023-01-19T08:14:15.694358272', '2023-01-19T08:14:16.901291520', '2023-01-19T08:14:18.108224768', '2023-01-19T08:14:19.315158016', '2023-01-19T08:14:20.522091264', '2023-01-19T08:14:21.729024768', '2023-01-19T08:14:22.935958016', '2023-01-19T08:14:24.142891264', '2023-01-19T08:14:25.349824512', '2023-01-19T08:14:26.556757760', '2023-01-19T08:14:27.763691264', '2023-01-19T08:14:28.970624512', '2023-01-19T08:14:30.177557760', '2023-01-19T08:14:31.384491008', '2023-01-19T08:14:32.591424256', '2023-01-19T08:14:33.798357760', '2023-01-19T08:14:35.005291008', '2023-01-19T08:14:36.212224256', '2023-01-19T08:14:37.419157504', '2023-01-19T08:14:38.626090752', '2023-01-19T08:14:39.833024256', '2023-01-19T08:14:41.039957504', '2023-01-19T08:14:42.246890752'], dtype='datetime64[ns]')
- (time)float64Hz1.287e-05, 0.359, ..., 0.352, -4.904e-07
Values:
array([ 1.28712703e-05, 3.58958178e-01, 7.17866971e-01, 1.07691359e+00, 1.43591746e+00, 1.79487320e+00, 2.15385369e+00, 2.51281987e+00, 2.87187121e+00, 3.23081226e+00, 3.58981891e+00, 3.94870842e+00, 4.30769912e+00, 4.66666365e+00, 5.02562219e+00, 5.38460756e+00, 5.74358259e+00, 6.10256219e+00, 6.46145332e+00, 6.82052313e+00, 7.17950960e+00, 7.53843141e+00, 7.89749324e+00, 8.25639496e+00, 8.61538232e+00, 8.97432454e+00, 9.33327696e+00, 9.69229285e+00, 1.00513025e+01, 1.04102649e+01, 1.07692595e+01, 1.11281847e+01, 1.14871807e+01, 1.18461841e+01, 1.22050969e+01, 1.25640798e+01, 1.29231327e+01, 1.32820439e+01, 1.36409727e+01, 1.40000353e+01, 1.40783218e+01, 1.40743732e+01, 1.40368964e+01, 1.39993821e+01, 1.39790818e+01, 1.39768099e+01, 1.39851619e+01, 1.39956710e+01, 1.40030625e+01, 1.40059890e+01, 1.40054666e+01, 1.40033109e+01, 1.40010291e+01, 1.39994742e+01, 1.39987127e+01, 1.39987323e+01, 1.39990919e+01, 1.39995409e+01, 1.39998985e+01, 1.40000804e+01, 1.40002122e+01, 1.40001767e+01, 1.40002364e+01, 1.40000597e+01, 1.40001066e+01, 1.40000218e+01, 1.40000392e+01, 1.39999286e+01, 1.40000235e+01, 1.40000074e+01, 1.39999709e+01, 1.39999340e+01, 1.39999981e+01, 1.40000250e+01, 1.40000138e+01, 1.39999544e+01, 1.40000262e+01, 1.39999248e+01, 1.39999959e+01, 1.40000356e+01, 1.39999859e+01, 1.40000064e+01, 1.40000152e+01, 1.39999306e+01, 1.40000103e+01, 1.40000394e+01, 1.40000453e+01, 1.39999621e+01, 1.39999829e+01, 1.40000255e+01, 1.40000156e+01, 1.40001212e+01, 1.40000104e+01, 1.40000177e+01, 1.40000035e+01, 1.40000708e+01, 1.39999885e+01, 1.40000239e+01, 1.39999674e+01, 1.39999966e+01, 1.40000941e+01, 1.39999697e+01, 1.40000485e+01, 1.39999898e+01, 1.39999805e+01, 1.40000370e+01, 1.39999218e+01, 1.39999741e+01, 1.40000058e+01, 1.40000634e+01, 1.39999932e+01, 1.40000127e+01, 1.39999972e+01, 1.40000141e+01, 1.40000016e+01, 1.39999793e+01, 1.39999465e+01, 1.39997833e+01, 1.39994304e+01, 1.39975883e+01, 1.39909586e+01, 1.39679657e+01, 1.39066992e+01, 1.38161344e+01, 1.37547382e+01, 1.37317718e+01, 1.37251129e+01, 1.37233104e+01, 1.37229065e+01, 1.37227973e+01, 1.37227971e+01, 1.37227141e+01, 1.37227334e+01, 1.37227890e+01, 1.37228152e+01, 1.37227866e+01, 1.37227864e+01, 1.37227676e+01, 1.37227788e+01, 1.37227187e+01, 1.37227271e+01, 1.33708923e+01, 1.30189700e+01, 1.26671450e+01, 1.23152892e+01, 1.19635346e+01, 1.16115960e+01, 1.12596457e+01, 1.09078860e+01, 1.05559769e+01, 1.02041706e+01, 9.85215034e+00, 9.50041097e+00, 9.14844059e+00, 8.79657999e+00, 8.44471935e+00, 8.09286946e+00, 7.74101219e+00, 7.38913836e+00, 7.03729080e+00, 6.68543505e+00, 6.33355645e+00, 5.98173710e+00, 5.62985157e+00, 5.27793131e+00, 4.92608422e+00, 4.57427248e+00, 4.22239068e+00, 3.87061422e+00, 3.51868112e+00, 3.16675315e+00, 2.81483757e+00, 2.46306945e+00, 2.11123897e+00, 1.75938373e+00, 1.40747555e+00, 1.05549357e+00, 7.03716252e-01, 3.51902452e-01, -4.90383720e-07])
The chopper has a long region of near-constant rotation speed surrounded by spin-up and spin-down regions:
[7]:
rotation_speed.plot(markersize=2)
[7]:
The central plateau is the section of the log where the chopper is in-phase with the source (the ESS source has a frequency of 14 Hz).
We use find_plateaus and collapse_plateaus to find one or more plateaus in the log data.
Note the atol and min_n_points parameters, they need to be tuned for the specific input data.
Warning
find_plateaus can potentially falsely identify regions with a small but steady slope as a plateau. See the function’s documentation for details.
[8]:
from scippneutron.chopper import collapse_plateaus, find_plateaus
plateaus = find_plateaus(
rotation_speed, atol=sc.scalar(1e-3, unit='Hz / s'), min_n_points=10
)
plateaus = collapse_plateaus(plateaus)
plateaus
[8]:
- plateau: 2
- plateau(plateau)int640, 1
Values:
array([0, 1]) - time(plateau, time [bin-edge])datetime64ns2023-01-19T08:12:10.173295104, 2023-01-19T08:13:28.623959553, 2023-01-19T08:13:39.486359296, 2023-01-19T08:13:55.176492033
Values:
array([['2023-01-19T08:12:10.173295104', '2023-01-19T08:13:28.623959553'], ['2023-01-19T08:13:39.486359296', '2023-01-19T08:13:55.176492033']], dtype='datetime64[ns]')
- (plateau)float64Hz14.000, 13.723
Values:
array([13.99993461, 13.7228163 ])
find_plateaus found two plateaus that we can plot with the following helper function:
[9]:
def plot_plateaus(raw_data: sc.DataArray, plateaus: sc.DataArray) -> None:
to_plot = sc.DataGroup({'Rotation Speed': raw_data})
for plateau in plateaus:
i = plateau.coords['plateau'].value
to_plot[f'Plateau {i}'] = sc.DataArray(
plateau.data.broadcast(dims=['time'], shape=[2]),
coords={'time': plateau.coords['time']},
)
return to_plot.plot(
ls={f'Plateau {i}': '-' for i in range(len(plateaus))},
marker={f'Plateau {i}': '|' for i in range(len(plateaus))},
markersize={'Rotation Speed': 2},
)
[10]:
plot_plateaus(rotation_speed, plateaus)
[10]:
In this case, the source has a frequency of 14Hz which means that plateau 0 is in phase. But plateau 1 is not, it is a short region where the chopper slowed down before fully stopping.
We can use filter_in_phase to remove all out-of-phase plateaus:
[11]:
pulse_frequency = sc.scalar(14.0, unit='Hz')
[12]:
from scippneutron.chopper import filter_in_phase
frequency_in_phase = filter_in_phase(
plateaus, reference=pulse_frequency, rtol=sc.scalar(1e-3)
)
frequency_in_phase
[12]:
- plateau: 1
- plateau(plateau)int640
Values:
array([0]) - time(plateau, time [bin-edge])datetime64ns2023-01-19T08:12:10.173295104, 2023-01-19T08:13:28.623959553
Values:
array([['2023-01-19T08:12:10.173295104', '2023-01-19T08:13:28.623959553']], dtype='datetime64[ns]')
- (plateau)float64Hz14.000
Values:
array([13.99993461])
[13]:
plot_plateaus(rotation_speed, frequency_in_phase)
[13]:
Since there is only one plateau left, we can simply index into it to get the chopper frequency:
[14]:
frequency = frequency_in_phase['plateau', 0]
frequency
[14]:
- plateau()int640
Values:
array(0) - time(time [bin-edge])datetime64ns2023-01-19T08:12:10.173295104, 2023-01-19T08:13:28.623959553
Values:
array(['2023-01-19T08:12:10.173295104', '2023-01-19T08:13:28.623959553'], dtype='datetime64[ns]')
- ()float64Hz13.999934613725587
Values:
array(13.99993461)
Inspecting TDC timestamps#
The top-dead-center (TDC) timestamps are created every time a marker placed on the chopper disk passes in front of a sensor mounted on the chopper module. It is essentially reporting at what times the chopper completed a full rotation.
[15]:
tdc = chopper['top_dead_center']
tdc
[15]:
- (time: 2354)datetime64ns2023-01-19T08:11:06.217830400, 2023-01-19T08:11:08.799053824, ..., 2023-01-19T08:14:39.174524416, 2023-01-19T08:14:40.640919296
Values:
array(['2023-01-19T08:11:06.217830400', '2023-01-19T08:11:08.799053824', '2023-01-19T08:11:09.873177088', ..., '2023-01-19T08:14:38.209520640', '2023-01-19T08:14:39.174524416', '2023-01-19T08:14:40.640919296'], dtype='datetime64[ns]')
The initial speed-up and late slow-down are visible when simply plotting all the TDC timestamps:
[16]:
tdc.plot()
[16]:
We can filter out the TDCs for the time when the chopper was in-phase by using the time range of the plateau we extracted above:
[17]:
low = frequency.coords['time'][0]
high = frequency.coords['time'][1]
tdc_in_phase = tdc[(tdc > low) & (tdc < high)]
tdc_in_phase
[17]:
- (time: 1098)datetime64ns2023-01-19T08:12:10.228627200, 2023-01-19T08:12:10.300058880, ..., 2023-01-19T08:13:28.514769152, 2023-01-19T08:13:28.586200832
Values:
array(['2023-01-19T08:12:10.228627200', '2023-01-19T08:12:10.300058880', '2023-01-19T08:12:10.371490560', ..., '2023-01-19T08:13:28.443337984', '2023-01-19T08:13:28.514769152', '2023-01-19T08:13:28.586200832'], dtype='datetime64[ns]')
We can check that the rate at which the TDC triggers is indeed close to 14Hz.
[18]:
diff = tdc_in_phase[1:] - tdc_in_phase[:-1]
rate = 1 / diff.to(unit='s', dtype='float64')
rate.min(), rate.max()
[18]:
(<scipp.Variable> () float64 [Hz] 13.9986,
<scipp.Variable> () float64 [Hz] 14.0002)
Computing chopper phase#
When constructing the DiskChopper at the start of this notebook, the phase was derived from a single delay value and the rotation speed setpoint.
It is however sometimes useful for debugging to compute the actual (time-dependent) phase of the chopper from the TDC information.
The phase can be defined as \(\phi = \omega (TDC - T_0)\), where \(\omega\) is the angular frequency of the chopper, \(TDC\) is a TDC timestamp, and \(T_0\) a pulse time.
We already determined the TDC timestamps above. In practice, we would get \(T_0\) from the input NeXus file, but here, we simply make one up:
[19]:
pulse_time = sc.datetime('2023-01-19T08:12:03.484012915', unit='ns')
Note
The pulse time is typically an array of timestamps and it can be difficult to determine which pulse goes with which chopper period.
[20]:
# We multiply by 1 rad to get the proper `rad*Hz` unit in `omega`
omega = 2 * sc.constants.pi * frequency.data * sc.scalar(1, unit='rad')
phase = omega * (tdc - pulse_time)
phase = phase.to(unit='deg') % sc.scalar(360.0, unit='deg')
phase.plot()
[20]:
We can clearly see the a central region where the phase is almost constant, and close to the value of 153.7° listed by the DiskChopper table at the top of the notebook (= 2.683 rad).
To each side of the central region are areas where the chopper is completely out of phase, during the spin-up and spin-down periods.
We can take a closer look at the data in the ‘in-phase’ region where we see some jitter around the target value:
[21]:
phase = omega * (tdc_in_phase - pulse_time)
phase = phase.to(unit='deg') % sc.scalar(360.0, unit='deg')
phase.plot()
[21]: