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

Frame unwrapping and WFM #472

Merged
merged 43 commits into from
Feb 26, 2024
Merged

Frame unwrapping and WFM #472

merged 43 commits into from
Feb 26, 2024

Conversation

SimonHeybrock
Copy link
Member

@SimonHeybrock SimonHeybrock commented Nov 30, 2023

This represents the latest approach to frame unwrapping and WFM (see old #415).

This clearly makes a bunch of assumptions which may turn out to be wrong in practice. At this point I have little confidence that this will be anything close to the final solution (or even that there is a solution at all — in the end we might still be forced to fall back to defining bounds more manually).

Testing is obviously challenging, and given that it is still not clear how all the choppers will be handled, I believe we must iterate on the whole solution (including what @jl-wynen has kicked of with #466) in the coming months.

Notes:

  • The old tof.frames was removed.
  • Adds tof.fakes, which I hope we can develop further for these and similar tests. This is currently my only idea on how to move forward: Build more realistic fakes, fix the chopper and unwrapping code, iterate.
  • Unlike the old unwrapping code, this can also handle event_time_zero. This will be essential for experiments with high time-resolution requirements.
  • Relies on the recently added scippneutron.tof.chopper_cascade module
  • My initial benchmarks indicate that using scipp.lookup over scipp.where and scipp.bin during unwrapping and WFM stitching (respectively) has equivalent or better performance. I think it is also clearer.
  • I added Sciline to the docs builds (so we can show the graphs), but not as a Scippneutron requirement. That is, we will provide providers (:grimacing: ) and domain types, but not more.

Fixes scipp/ess#165.

Follow-up work:

@nvaytet
Copy link
Member

nvaytet commented Nov 30, 2023

I had a short look but I think it would be much easier for me if we looked at the graphs together and you can explain the different steps, as well as the differences between the different graphs.

# Find last frame before ltotal
frame_before_detector = None
for frame in frames:
if frame.distance > ltotal:
Copy link
Member

Choose a reason for hiding this comment

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

Just checking: I guess we are guaranteed that the frames are ordered according to 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.

Yes, chopper_toolkit sorts the choppers before applying them.

choppers: Choppers, source_chopper_name: SourceChopperName
choppers: Choppers,
source_time_range: SourceTimeRange,
source_chopper_name: Optional[SourceChopperName],
Copy link
Member

Choose a reason for hiding this comment

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

Should source_chopper_name have a default None value?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sciline does not use default values.

are used to define a fake chopper.
"""
if source_chopper_name is not None:
return choppers[source_chopper_name]
Copy link
Member

Choose a reason for hiding this comment

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

So if the source_chopper_name exists, then source_time_range is unused. Should that also be an optional input?

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 don't think so, because then the pipeline would fail at runtime if neither is given?

Copy link
Member

Choose a reason for hiding this comment

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

Say you know the source chopper name, but not the source time range, you'd still have to supply a source time range as input.
If you don't know it, you could always make it to be nonsense because it won't be used, but it seems a little strange to require it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Without a source time range the whole chopper-cascade math won't work. This is required anyway for other parts of the workflow.

Copy link
Member

Choose a reason for hiding this comment

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

Should we then have a check to make sure that the source_time_range is consistent with the source chopper if source_chopper_name is given?

Copy link
Member Author

Choose a reason for hiding this comment

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

How would that be possible?

Copy link
Member

Choose a reason for hiding this comment

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

Couldn't we check that the chopper open and close times match the source_time_range? Or maybe I misunderstood something and in some part of the pipeline you need the source chopper, and in others you need the source_time_range and they can in principle be different? (e.g. if the source chopper is not at a distance of 0.0?)

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 "source chopper" may define a different time range, i.e., it is a pulse-shaping chopper to increase the resolution. It will typically have a tighter time range.



def offset_to_time_of_flight_wfm(
time_offset: OffsetFromWrapped,
Copy link
Member Author

@SimonHeybrock SimonHeybrock Jan 31, 2024

Choose a reason for hiding this comment

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

I think this may actually be wrong. I think we need the unwrapped time offset, not just the offset from wrapped to perform the WFM TOF offsetting.

Converting to draft until verified.

@SimonHeybrock SimonHeybrock marked this pull request as draft January 31, 2024 14:35
wavelength_max=source_wavelength_range[-1],
)
frames = frames.chop(choppers.values())
return FrameAtDetector(frames[ltotal])
Copy link
Contributor

@jokasimr jokasimr Feb 6, 2024

Choose a reason for hiding this comment

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

Doesn't this assume:

  • that the neutrons move in straight lines to the detector elements?
  • that there is no inelastic scattering?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct. See my comment in the PR:

This clearly makes a bunch of assumptions which may turn out to be wrong in practice. At this point I have little confidence that this will be anything close to the final solution (or even that there is a solution at all — in the end we might still be forced to fall back to defining bounds more manually).

@SimonHeybrock
Copy link
Member Author

@nvaytet @jl-wynen @jokasimr I'd like some input here, on the latest changes.

Copy link
Member

@jl-wynen jl-wynen left a comment

Choose a reason for hiding this comment

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

I don't have anything enlightening to say. This just looks like what we talked about.

Copy link
Member

Choose a reason for hiding this comment

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

Typo in l73: "For, e.g., detector 1 when then observe:\n", should be "For, e.g., detector 1 we then observe:\n",. And 'then' can be left out, too.

" $$\n",
" \n",
"Note that there are other valid definitions of $t_\\text{pivot}$, differing only in how neutrons in the intermediate \"background\" region (region between two pulses) are mapped to frames."
"As illustrated in the figure, the pivot time $t_\\text{pivot}$ depends on the detector or rather the distance of the detector (or monitor) from the scattering position.\n"
Copy link
Member

Choose a reason for hiding this comment

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

In the diagram, the pivot for detector 1 is at the black dashed line, i.e., the beginning of the orange frame. But in detector 2 it is in the middle between the frames.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure what you are describing. Doesn't it look like in the docs for you?
image

docs/api-reference/frame-unwrapping.ipynb Outdated Show resolved Hide resolved
docs/api-reference/frame-unwrapping.ipynb Outdated Show resolved Hide resolved
docs/api-reference/frame-unwrapping.ipynb Outdated Show resolved Hide resolved
"metadata": {},
"source": [
"At the sample or a detector we would only see this for a particular `Ltotal`:"
"Choppers may be used to skip pulses, for the purpose of a simultaneous study of a wider wavelength range.\n",
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean by simultaneous 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.

You can use every pulse, measure 1-4 Angstrom, change the choppers, measure 4-8 Angstrom, and combine the results. That would be serial, as opposed to simultaneous (I think this is the term I have heard scientists use).

src/scippneutron/tof/unwrap.py Outdated Show resolved Hide resolved
Copy link
Member

@nvaytet nvaytet left a comment

Choose a reason for hiding this comment

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

A couple of questions.

Also, maybe I've asked this before, but why is this notebook in the API reference? It makes it pretty difficult to find in the docs I feel.

@@ -378,7 +388,9 @@ def time_of_flight_origin_from_chopper(
source_time_open = source_chopper.time_open[0]
source_time_close = source_chopper.time_close[0]
time_zero = 0.5 * (source_time_open + source_time_close)
return TimeOfFlightOrigin(time_zero)
return TimeOfFlightOrigin(
time=time_zero, L1=sample_distance - source_chopper.distance
Copy link
Member

Choose a reason for hiding this comment

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

Is the TimeOfFlightOrigin ill-defined if there is no sample in the run, or if you are trying to unwrap times for a monitor?

Copy link
Member Author

Choose a reason for hiding this comment

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

It does not matter if there is no sample, this is about the definition of L1. This (or sample_position) has to be defined even if there is no "sample", since all the coord transforms in ScippNeutron rely on it.

But you have actually stumbled upon a related problem: If we are unwrapping a monitor then the coord transforms defined in ScippNeutron do not use L1 but Ltotal, so we should be correcting that.

Copy link
Member Author

Choose a reason for hiding this comment

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

@nvaytet Please have a look at the latest commit, this should fix the aforementioned issue.

Copy link
Member

Choose a reason for hiding this comment

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

👍 Remember to add a test that checks for this when you get round to adding tests.

return TimeOfFlightOrigin(sc.DataArray(neg_shift, coords={'subframe': times}))
return TimeOfFlightOrigin(
time=sc.DataArray(neg_shift, coords={'subframe': times}),
L1=sample_distance - source_chopper.distance,
Copy link
Member

Choose a reason for hiding this comment

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

Should they be distances or should they be defined as vector positions in space and take the norm of the difference?

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 chopper_cascade using a simple distance, so this is what we have to use here. This will hopefully allow for hiding details such as curved guides (we do not want to chopper_cascade toolkit to deal with such complications, if possible).

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 still don't really understand the WFM case and why it's necessary to treat it separately. My understanding was that given any chopper configuration we can propagate the source pulse through the cascade and compute the wavelength-time diagram at every detector(element), and from it + the event time data we can compute the time of flight. From that perspective the WFM case is just one particular chopper configuration and it's not clear to me why it needs a separate code path.

But this shouldn't be an obstacle to merge this, just a heads up that this is probably something we should talk about again.

TimeOffset = NewType('TimeOffset', sc.Variable)
"""Unwrapped time offset relative to the pulse time."""

DeltaFromWrapped = NewType('DeltaFromWrapped', sc.Variable)
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a docstring to describe what DeltaFromWrapped represents

Copy link
Member Author

Choose a reason for hiding this comment

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

Did you review an outdated diff?

@@ -311,7 +314,7 @@ def offset_from_wrapped(
+ sc.concat([frame_period, sc.zeros_like(frame_period)], dim),
coords={dim: time.to(unit=elem_unit(wrapped_time_offset))},
)
return OffsetFromWrapped(sc.lookup(offset, dim=dim)[wrapped_time_offset])
return DeltaFromWrapped(sc.lookup(offset, dim=dim)[wrapped_time_offset])
Copy link
Contributor

@jokasimr jokasimr Feb 12, 2024

Choose a reason for hiding this comment

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

Would it be possible to use interpolate here? If so, is there a reason why lookup is advantageous

Copy link
Member Author

@SimonHeybrock SimonHeybrock Feb 13, 2024

Choose a reason for hiding this comment

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

scipp.lookup can handle event data, interpolate cannot, and may be slower.

time_offset: OffsetFromWrapped,
source_chopper: SourceChopper,
) -> OffsetFromTimeOfFlight:
def time_of_flight_origin(source_chopper: SourceChopper) -> TimeOfFlightOrigin:
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do the user have to define the SourceChopper? Can't that be automatically inferred from the description of the chopper setup?

Copy link
Member Author

Choose a reason for hiding this comment

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

No, not really, it is not necessarily the first chopper, and it might not even be an actual chopper, but a "virtual" one that one might define (e.g., to use the source pulse as "source chopper").

@SimonHeybrock
Copy link
Member Author

Also, maybe I've asked this before, but why is this notebook in the API reference? It makes it pretty difficult to find in the docs I feel.

It used to be more visible, but was moved there during the switch to the new theme. @jl-wynen can comment on why it is here or where we can move it.

@SimonHeybrock
Copy link
Member Author

From that perspective the WFM case is just one particular chopper configuration and it's not clear to me why it needs a separate code path.

It does not a different code part, for the most part. But the time-of-flight origin needs to be defined differently, in particular different neutrons within a frame have a different origin.

Setting L1 would have been broken for monitors. Furthermore, it might
have been ignored if Ltotal was computed previously.
@nvaytet
Copy link
Member

nvaytet commented Feb 13, 2024

Green light from me for this round of review.

Do I remember correctly that you said you were going to add tests after this round of review?

@SimonHeybrock SimonHeybrock marked this pull request as ready for review February 20, 2024 09:04
@SimonHeybrock
Copy link
Member Author

@nvaytet Tests updated!

@@ -505,14 +508,21 @@ def to_time_of_flight(
if da.bins is not None:
da = da.copy(deep=False)
da.data = sc.bins(**da.bins.constituents)
da.bins.coords['tof'] = time_offset + delta
da.bins.coords['time_zero'] = da.bins.coords['pulse_time'] - delta.to(
da.bins.coords['tof'] = time_offset - delta
Copy link
Member

Choose a reason for hiding this comment

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

👍

@SimonHeybrock SimonHeybrock merged commit 22ee6eb into main Feb 26, 2024
6 checks passed
@SimonHeybrock SimonHeybrock deleted the unwrap branch February 26, 2024 03:48
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.

WFM for scattering beamlines
4 participants