Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alternative to time-origin tof computation #577

Merged
merged 57 commits into from
Dec 11, 2024
Merged

Alternative to time-origin tof computation #577

merged 57 commits into from
Dec 11, 2024

Conversation

nvaytet
Copy link
Member

@nvaytet nvaytet commented Nov 25, 2024

This is an alternative implementation of the computation of the tof coordinate in the unwrap submodule.

The motivations are the following:

  1. Simplify the code in tof/unwrap.py (finding the tof origin with WFM was complex with a fake chopper and propagating frames backwards along the beamline)

  2. Generalize the code so that we only have to use a single set of providers instead of having providers for standard unwrap, pulse-skipping and WFM (only the uncommon case where there are no choppers requires different providers).

Implementation

The implementation is basically described in https://tof.readthedocs.io/en/stable/wfm.html.

  • We use the chopper_cascade module to find the ranges of time and wavelength (polygons) that are reachable at the detector pixels.
  • We approximate the polygons with straight lines
  • We use these to define lookup tables which give us directly the tof as a function of arrival time at detector

Copy link
Contributor

@jokasimr jokasimr left a comment

Choose a reason for hiding this comment

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

Overall this looks more or less complete to me. Great job. (-300 SLOC is nice)
Left a couple of comments of things I think we should change before merging.

a /= weight.sum(dims)
# Compute the intercept as the mean of the intercepts of the curves that pass
# through the vertices
b = (y - a * x).mean('vertex')
Copy link
Contributor

@jokasimr jokasimr Nov 25, 2024

Choose a reason for hiding this comment

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

I think we should use another method to determine the line that best approximates the polygon.
While this method works well in the cases we've seen so far it is not obvious that it will always produce something reasonable.

For example, if two vertices have almost the same x-coord the slope will grow a lot and the average slope might be larger than expected.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes I forgot to look at the notebook you sent.
Thanks for reminding me.

Copy link
Member Author

Choose a reason for hiding this comment

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

The suggested method indeed yields better results 👍

Old:
Screenshot_20241126_142030

New:
Screenshot_20241126_141751

# begin of the first bin, there may be a significant gap (which we could fill
# with NaNs or zeros), but there could also be a small overlap.
return UnwrappedData(da)
return TofCoord(toa)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we not assume the neutron left in the middle of the pulse? (Just because that makes a slightly better estimate.)
That is, shouldn't this be something like toa - sc.scalar(3, unit='ms')/2?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, good point, we could do that 👍

unsorted bin edges (due to either wrapping of `time_of_flight` or wavelength
overlap between subframes).
This function re-histograms the data to ensure that the bin edges are sorted.
It generates a number of events in each bin with a normal distribution around
Copy link
Contributor

@jokasimr jokasimr Nov 25, 2024

Choose a reason for hiding this comment

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

It seems to me this function is essentially making a change of variables in a distribution function (or histogram).

That is, starting with a distribution over toa and a function tof(toa), we want to compute the distribution over tof.

To do this we should use the formula here: https://en.wikipedia.org/wiki/Probability_density_function#Function_of_random_variables_and_change_of_variables_in_the_probability_density_function (the not monotonic case)

If we used that I'm sure we'd end up with a simpler solution here.

Copy link
Member Author

Choose a reason for hiding this comment

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

If you could give me some pointers on how you would implement this, I'd be happy to try. I don't think I fully understood what's in the link.

Copy link
Member Author

@nvaytet nvaytet Nov 26, 2024

Choose a reason for hiding this comment

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

Note also that this function is just to return something which is like the original data, but can be used further for normalization etc.
The real data is actually the TofData, but that has unsorted bin edges.
So maybe the details of this function are not super important because we can swap it out for a different/better provider later?
It was mostly designed so I could write some tests where I compare the wavelengths...

Copy link
Contributor

Choose a reason for hiding this comment

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

I was looking at this now and it became more complicated than I thought it would be 🤔
What you've already written might be the better solution.
Like you said, if we come up with something later it can be revisited.

@nvaytet nvaytet marked this pull request as ready for review December 2, 2024 13:52
@jokasimr
Copy link
Contributor

jokasimr commented Dec 2, 2024

I'll take another look tomorrow!

@jokasimr
Copy link
Contributor

jokasimr commented Dec 5, 2024

I'm still working on the review, it'll be done later today

Copy link
Contributor

@jokasimr jokasimr left a comment

Choose a reason for hiding this comment

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

I think there might be a wrong assumption here that the minimum event_time_zero in the file is also the start of a stride in pulse skipping mode.

But what might happen is that the user starts writing the file in the middle of the stride when pulse skipping is used.

Is that taken into consideration here?

Note that this is probably not an issue for simulated data.

Comment on lines 272 to 273
# the boundaries have crossed, which means that the chopper is rotating
# must rotate at a lower frequency.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
# the boundaries have crossed, which means that the chopper is rotating
# must rotate at a lower frequency.
# the boundaries have crossed, which means that the chopper
# is rotating at a lower frequency.

"""
if da.bins is None:
# Canonical name in NXmonitor
return PulseWrappedTimeOffset(da.coords['time_of_flight'])
return PulseWrappedTimeOffset(da.bins.coords['event_time_offset'])
toa = da.coords['time_of_flight']
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the time_of_flight field already unwrapped? That is, does it denote true time of flight, or does it just denote time of arrival but has a misleading name?

Copy link
Member Author

Choose a reason for hiding this comment

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

The latter. It's just the name that is given in NeXus files.

# Hence we use the smallest event_time_zero as the time origin.
time_zero = da.coords['event_time_zero'] - da.coords['event_time_zero'].min()
coord = da.bins.coords['event_time_offset']
toa = coord + time_zero.to(dtype=float, unit=elem_unit(coord), copy=False)
Copy link
Contributor

@jokasimr jokasimr Dec 5, 2024

Choose a reason for hiding this comment

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

I think there might be something wrong here in the case when pulse skipping is used.

We know that

toa = event_time_offset + event_time_zero
= event_time_offset + frame_time_zero + frame_time_offset

where frame_time_zero is the time the start of the latest "logical pulse" (the start of the most recent pulse that wasn't blocked by the pulse-skip chopper) and frame_time_offset = event_time_zero - frame_time_zero.
From the chopper cascade we know that the frame takes frame_travel_time to reach the detector.
Therefore the frame starts at start_time = frame_travel_time + frame_time_zero.

Then

toa_minus_start_time_modulo_frame_period
= (toa - start_time) % frame_period
= (event_time_offset + event_time_zero - start_time) % frame_period
= (event_time_offset + frame_time_offset - frame_travel_time) % frame_period

so I'd suggest that we

  1. Remove UnwrappedTimeOfArrival since (it seems) it's only used to compute UnwrappedTimeOfArrivalMinusStartTime.
  2. Rename FrameAtDetectorStartTime to FrameTravelTime.
  3. Add a provider that computes FrameTimeOffset - this might be complicated. Typically I guess we have to get this from the chopper configuration somehow. For simulated data this is easy because then frame_time_offset = (event_time_zero - minimum_event_time_zero) % frame_period just like in the code here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I fixed the case where a pulse skipping chopper would have a phase offset of 180 deg by propagating multiple pulses through the chopper cascade to predict frame at detector location.

I fixed the potential issue of starting to record in the middle of a pulse-skipping frame by adding a PulseStrideOffset integer parameter, which can be set by the user if the data doesn't look right, and which is set to 0 by default.

time_zero = 0.5 * (source_time_open + source_time_close)
return TimeOfFlightOrigin(time=time_zero, distance=source_chopper.distance)
return TimeOfArrivalModuloPeriod(
toa % frame_period.to(unit=elem_unit(toa), copy=False)
Copy link
Contributor

@jokasimr jokasimr Dec 5, 2024

Choose a reason for hiding this comment

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

Related to the above comment. What we want here is the time since the most recent frame_time_zero.

That is event_time_offset + frame_time_offset.

In a simulation we know that the first event_time_zero coincides with the first frame_time_zero and then what is implemented here is correct.

@jokasimr
Copy link
Contributor

Looks good to me!

@nvaytet nvaytet merged commit 2fc22c5 into main Dec 11, 2024
5 checks passed
@nvaytet nvaytet deleted the new-wfm branch December 11, 2024 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

3 participants