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

Update of WFM tof workflow to take into account DREAM choppers #560

Merged
merged 53 commits into from
Nov 13, 2024
Merged

Conversation

nvaytet
Copy link
Member

@nvaytet nvaytet commented Oct 14, 2024

The DREAM pulse-shaping choppers (PSC) are different from the V20 or ODIN WFM choppers in the way that they have 8 cutouts that can shape the pulse in different ways.

In the example McStas simulation that we have, the detector sees 2 frames made from 8 cutouts.
The workflow that computed time-of-flight from event-time-offset that we had assumed that the number of frames was equal to number of cutouts.

This PR updates the workflow to handle both cases.
Note that we re-wrote the workflow graph to simplify it, from
old-graph
to
new-graph

@nvaytet nvaytet marked this pull request as ready for review October 14, 2024 15:37
Copy link
Member

@SimonHeybrock SimonHeybrock left a comment

Choose a reason for hiding this comment

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

Big congrats on getting this to work! 👍

I have only looked at the code changes, not the notebooks. Maybe a walkthrough would be best?

-----
To find the time-of-flight origin, we ray-trace the fastest and slowest neutrons of
the subframes back to the first chopper to determine the time-of-flight origin.
The assumption here is that the first chopper is one of the two wfm choppers.
Copy link
Member

Choose a reason for hiding this comment

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

Is this the case for all ESS instruments with WFM?

Comment on lines 513 to 518
# Ray-trace back to the position of the first chopper (note that frame 0 is the
# pulse itself)
distance = frames[1].distance
dist = ltotal - distance
t1 = tmin - dist * chopper_cascade.wavelength_to_inverse_velocity(wmin)
t2 = tmax - dist * chopper_cascade.wavelength_to_inverse_velocity(wmax)
Copy link
Member

Choose a reason for hiding this comment

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

chopper_cascade can propagate forwards, e.g., to the detector position. Can't the same code be used to go backwards?

Copy link
Member Author

Choose a reason for hiding this comment

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

That would be great, but I wasn't immediately able to figure that out. Would be super if you can help with that!

Copy link
Member

Choose a reason for hiding this comment

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

I think you could just use

Copy link
Member

@SimonHeybrock SimonHeybrock Oct 15, 2024

Choose a reason for hiding this comment

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

Or use

def propagate_by(self, distance: sc.Variable) -> Subframe:

if we can have a proper frame setup?

Comment on lines -338 to -340
# We currently have no way of detecting which cutouts of the "source" chopper
# are used (not blocked by other choppers), for this test we remove those we
# know are not used.
Copy link
Member

Choose a reason for hiding this comment

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

Maybe replace by comment that explains (and assertion) that there are extra openings but the code knows how to deal with them automatically?

"source": [
"# DREAM in WFM mode\n",
"\n",
"In this notebook, we show how to use Scippneutron's `tof` module to find the boundaries of the WFM frames for the DREAM instrument.\n",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"In this notebook, we show how to use Scippneutron's `tof` module to find the boundaries of the WFM frames for the DREAM instrument.\n",
"In this notebook, we show how to use ScippNeutron's `tof` module to find the boundaries of the WFM frames for the DREAM instrument.\n",

"metadata": {},
"outputs": [],
"source": [
"psc1 = DiskChopper(\n",
Copy link
Member

Choose a reason for hiding this comment

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

Is this setup for the case of overlapping subframes, as in the McStas data? I have not seen how that is being handled?

Comment on lines 386 to 389
start_rotation:
This can be used to include rotations before the first pulse by giving a
negative integer. This is useful for choppers that are not in phase with
the source. The default is 0.
Copy link
Member

Choose a reason for hiding this comment

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

Is there ever a case where we don't want to include such rotations? That is, is this argument even needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed this to now always have the -1 rotation.

Comment on lines 441 to 462
n_repetitions:
Return this many times for each angle corresponding to multiple rotations
of the chopper.
start_rotation:
This can be used to include rotations before the first pulse by giving a
negative integer. This is useful for choppers that are not in phase with
the source. The default is 0.
end_rotation:
How many chopper rotations to perform.
Copy link
Member

Choose a reason for hiding this comment

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

Same question as above: Maybe the existing argument was fine and we should just ensure that we always include the start?

Comment on lines 253 to 270
# If end is after current end, there is only partial overlap, so we clip
if bound.end > current.end:
old_end = current.end
old_wav_end = current.wav_end
current = Bound(
current.start,
bound.start,
current.wav_start,
bound.wav_start,
)
merged_bounds.append(current)
current = Bound(
old_end,
bound.end,
old_wav_end,
bound.wav_end,
)
# If end is before current end, overlap is total and we merge
Copy link
Member

Choose a reason for hiding this comment

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

I don't get this. What is being clipped? What is meant be "overlap is partial" and why don't we just want to merge?

@nvaytet
Copy link
Member Author

nvaytet commented Nov 4, 2024

Note that CI will fail until scipp/scipp#3582 is merged and released.

@nvaytet nvaytet marked this pull request as ready for review November 4, 2024 18:31
@nvaytet
Copy link
Member Author

nvaytet commented Nov 4, 2024

@SimonHeybrock I believe this is ready for another look.

Comment on lines 510 to 511
# Start at -1 to ensure a rotation that is finishing when the pulse begins is
# also included.
Copy link
Member

Choose a reason for hiding this comment

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

Not sure what this comment relates to?

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed. Was left over from previous changes.

sample. The frame bounds are then computed from this.
"""
return FrameAtDetector(frames[-1].propagate_to(ltotal))
# return FrameAtDetector(frames[ltotal])
Copy link
Member

Choose a reason for hiding this comment

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

Commented code?

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 actually left it in there intentionally because I wasn't sure if I should use the getitem syntax.
I believe I needed to make the change because inside the getitem it assumes that the distance passed is a scalar.

Should I fix in the getitem or should I keep the code I have now?

To fix in the getitem, we would have to handle cases where you propagate to a range of distances, some might be before the last frame, some might be after... It gets messy.
I could also just raise a helpful error message if the distance passed in the getitem is not a scalar?

Copy link
Member

Choose a reason for hiding this comment

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

I don't have a strong opinion, do what you think works best.

src/scippneutron/tof/unwrap.py Outdated Show resolved Hide resolved
Comment on lines +459 to +460
In the case of multiple detector pixels, we find the pixel closest to the source
and use that as the reference for the clipping.
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, shouldn't you get more overlap for pixels with the greatest distance?

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 think it doesn't matter because it will just propagate to the other distances and there are no more choppers in the way.

Copy link
Member

Choose a reason for hiding this comment

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

My point was that if there are two subframes that to not overlap at L2=5 meter, they may overload at any greater L2. Or do I misunderstand the comment?

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, you're right, the frames may not overlap on the closest pixel.
However, if we take the overlapping times at the farthest pixel, and use those to make a fake chopper, those times would not match the overlap seen at the closest pixel, they would be shifted to the right.
So the closest frame would have a small region where there is still overlap.

Basically, I think I need to find the overlap at the farthest pixel, and propagate those backwards to the closest pixel distance, and then make my fake chopper...

As discussed, this fake chopper implementation is not great.
@jokasimr and I do have a prototype of an alternative method which is working.
Question is: should we spend time on fixing this, or should we just try to get that into a production shape instead of trying to fix this implementation?

We could leave this one as is and assume this edge case won't be an issue in the next couple of weeks.
We then can roll out the alternative version a short time after?

Copy link
Member

Choose a reason for hiding this comment

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

Why is it an edge case? Doesn't it affect all (WFM) beamlines that do not have pixels on a sphere? Or do you mean the need for clipping is an edge case (unusual DREAM settings?).

Copy link
Member Author

@nvaytet nvaytet Nov 12, 2024

Choose a reason for hiding this comment

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

I mean that both the DREAM choppers are a little unusual, and that the case where some pixels have overlap and others don't is hopefully an edge case as the range of pixel distances is not too large for a given instrument?
(I guess Loki is the odd one out, but they don't have WFM choppers)

Copy link
Member

Choose a reason for hiding this comment

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

Then we should just use the furthest right (and live with throwing away a tiny bit too many events for the closer pixels)? Otherwise you always get overlap for all but the closest pixel.

Copy link
Member Author

Choose a reason for hiding this comment

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

Let's find a time to chat in a call.

Copy link
Member Author

Choose a reason for hiding this comment

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

Conclusion from the call: because we are making a gap when adding the fake chopper rather than clipping with an infinitely thin vertical line, the distance it would take for the gap to completely fill in again with overlap is hopefully larger than the range of pixel distances.
So the overlap should be removed for all pixels (in most cases).
user-guide_wfm_dream-wfm_49_0

of the WFM choppers.
Check for time overlap between subframes.
If overlap is found, we clip away the regions where there is overlap.
This is done by adding a fake chopper which is closed during the overlapping times.
Copy link
Member

Choose a reason for hiding this comment

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

Not clear to me why we still need choppers at this point. Instead of adding a fake chopper, can't we just clip the (sub)frame bounds?

Comment on lines +503 to +521
# We cannot chop frames at multiple distances at once, so instead of chopping the
# frame at the detector, we chop the last frame in the chopper cascade before the
# detector.
last_frame = chopper_cascade_frames[-1]

# Chop the subframes one by one
# TODO: We currently need to chop them one by one as the `chop` method seems
# to not work correctly with overlapping subframes.
subframes = []
for subframe in last_frame.subframes:
f = chopper_cascade.Frame(distance=last_frame.distance, subframes=[subframe])
chopped = f.chop(fake_chopper)
subframes.extend(
[
sf
for sf in chopped.subframes
if not sc.allclose(sf.start_time, sf.end_time)
]
)
Copy link
Member

Choose a reason for hiding this comment

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

All this (and above) looks really complicated (read: very difficult to verify). I wonder if it is due to the fake-chopper. Why can't we just compute bounds, clip, and continue?

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 am using chop because I need to have a well-constructed frame, that I can then propagate backwards to find the time origin.
I could just clip the time range, but I also want to clip the wavelengths in the correct way, so the backward propagation is correct. I thought the best way was to use chop.

Do you have a another suggestion?

Copy link
Member

Choose a reason for hiding this comment

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

The frame-propagation is already a free function. Wouldn't it still be easier to compute time and wavelength bounds directly?

Copy link
Member Author

Choose a reason for hiding this comment

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

Finding the time bounds is easy enough (it's just about sorting the time bounds along the subframe dimension).
Do you have a vectorized way of clipping the subframe to find the wavelengths?

The code in the _chop() function does not handle this, and I can't see an easy way to adapt it...

Copy link
Member

Choose a reason for hiding this comment

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

I honestly do not remember, it has been too long since I wrote this. We can have a look together one day if you like?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm I think I may have a way to do this, by taking the mean of the intersection from all vertices of the subframe.

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've looked into simplifying this without using the fake chopper, for many pixels in a vectorized way. It's proving difficult.

My suggestion would be to merge this implementation so that it doesn't block all the other workflows.

I will revisit right after with a simpler approach but it should only be internal refactoring and shouldn't change how it is used by workflows.

Comment on lines 619 to 620
# TODO: what dimension order is the best here? Where should `detector_number` be?
origin_time = sc.DataArray(shift.transpose(times.dims), coords={'subframe': times})
Copy link
Member

Choose a reason for hiding this comment

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

If this is meant for processing the raw data, detector_number should be first (slowest dim), since that is how data is sorted.

Comment on lines 690 to 691
# TODO: Can we do without making a copy of delta?
delta = sc.lookup(delta.copy(deep=True), dim='subframe')[time_offset]
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't/isn't sc.lookup doing this?

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 think you're right.

@nvaytet
Copy link
Member Author

nvaytet commented Nov 12, 2024

@SimonHeybrock any final comments?

@nvaytet nvaytet merged commit 7263415 into main Nov 13, 2024
5 checks passed
@nvaytet nvaytet deleted the dream-wfm branch November 13, 2024 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants