-
Notifications
You must be signed in to change notification settings - Fork 2
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
feat: rectangular approximation for cylinder solid angle #29
Conversation
src/esssans/normalization.py
Outdated
w *= R / sc.norm(w) | ||
|
||
# Three corners form a triangle | ||
a = f1c - w |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Triangle coordinates should be relative to origin.
src/esssans/normalization.py
Outdated
"""Approximation of the solid angle of a cylinder as seen from `origin`. | ||
The approximation does not take edge caps into account. | ||
|
||
Cylinder is approximated by a rectangle cross section parallel | ||
to the axis of the cylinder. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wold not call this an approximation: I think it is actually exact if we want to avoid double counting, given that there are typically many cylinders in a row.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not 100% sure but I think it is still approximate.
It is approximate, consider for example if we place a relatively large cylinder close to the 'origin' then the difference will be large.
Found this where they describe an exact analytical formula for the solid angle of a cylinder. It is split into three contributions, one from the body and two from the end-caps. Even if we ignore the contributions from the end-cap surfaces the body contribution is not the same as what we have.
src/esssans/normalization.py
Outdated
@@ -64,6 +64,41 @@ def solid_angle_rectangular_approximation( | |||
) | |||
|
|||
|
|||
def solid_angle_approximation_for_cylinders(cylinders: sc.DataSet, origin: sc.Variable): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would have though we need:
- Cylinder spec (from NXcylindrical_geometry)
- Cylinder positions (from
scippnexus.compute_positions
). To be added to base cylinder spec. - Sample position (not necessarily exactly the origin)
Also, this probably has to be done after positions calibrations such as beam-center finding. We should use Sciline with domain types to ensure this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
origin
here might be a bad name, it's the "point of view" from where the solid angle is observed. In our case I guess we would pass the sample position there.
For this function to work we need that face_center
and face_edge
and the origin are points in the same (orthonormal) coordinate system. To make that the case I think we will need the cylinder spec + cylinder positions + sample position like you say. But my thinking was that this could be something handled by the caller.
src/esssans/normalization.py
Outdated
|
||
return SolidAngle[RunType]( | ||
approximate_solid_angle_for_cylinder_shaped_pixel_of_detector( | ||
# Is there a way to get the positions of the pixels without going through the events? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should assume that the input is a DataArray
with a position
coord, not the parent data group. This may be containing events or not. For the lowest-level function it may make sense to just take the position.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should I assume the data array also has the pixel_shape
data or should that enter from a separate provider?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The array does not have pixel shape, since it is stored in a DataGroup
, on a higher level, i.e., use a separate provider.
src/esssans/normalization.py
Outdated
) | ||
|
||
|
||
def approximate_solid_angle_for_cylinder_shaped_pixel_of_detector( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd remove the whole thing about "approximation". You can briefly mention it in the docstring, but the distance is always large in SANS.
Also, is it really an approximation? Given that the area of a trapezoid is (a+b)*h/2, I'd think it might be exact, given that we use the midpoint to compute the distance?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm all for removing the prefix approximate_
if this is the standard approximation typically used in this setting.
Not sure what you mean by it might be exact. The area is not the same as the solid angle. The solid angle is the area projected to the sphere, if the sphere is large and the area is small they are certainly close but it's still an approximation.
That the distance to the midpoint is used as an estimate of the distance to every point on the surface does not change this. The midpoint might be the closest point for all we know.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right. I was thinking about the perspective effect, but the projection is still another effect that is being ignored.
1041691
to
3994ab0
Compare
src/esssans/normalization.py
Outdated
detector['pixel_shape']['vertices']['vertex', i] for i in range(3) | ||
) | ||
cylinder_axis = ( | ||
detector['transform'] * face_2_center - detector['transform'] * face_1_center |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I see why you called it DetectorGeometry
. In practice this is the parent group of the raw data. I don't think we should expose this detail or any of the names ('pixel_shape'
and 'transform'
) here. Those names can vary, depending on how files are created. This function should takes pixel_shape
(or cylinder_geometry
) and transform
as separate inputs.
5d1e22d
to
cc0a972
Compare
src/esssans/normalization.py
Outdated
""" | ||
norm_pp = sc.norm(pixel_position) | ||
norm_ca = sc.norm(cylinder_axis) | ||
cosalpha = sc.norm(sc.cross(pixel_position, cylinder_axis)) / (norm_pp * norm_ca) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pixel_position
.
The angle between pixel_position
and cylinder_axis
is
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I remember yesterday cylinder_axis
components (i.e., projected out). Is this equivalent?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The definition of solid angle is
The cylinder surface is approximated by a rectangle cross section of the cylinder, called
The solid angle of the rectangle
From the above it is clear that we want cosine of the angle between the position and the normal of the surface. I think it is equivalent to what we did yesterday.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which surface normal?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is the rectangle approximating the cylinder surface selected?
It is selected as the rectangle contained in
-
$\hat{S}$ is parallel to the axis of the cylinder:$n \cdot c = 0$ -
$\hat{S}$ is selected to maximize$\hat{r} \cdot n$ . Under the constraint in (1) this occurs when$n$ ,$\hat{r}$ and$c$ all lie in the same plane.
Call the angle between
- From (1): The angle between
$n$ and$c$ is$\pi/2$ . - From (2):
$\alpha + \theta + \pi/2 = \pi \ \iff \theta = \pi/2 - \alpha$
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make any performance difference if we used the dot-product instead to compute the same? It would avoid creating an intermediate array of vectors (and have an array of scalars instead)?
Yes maybe there's a better way.
We could do:
From 1 and 2:
This looks like what you talked about before.
Timed it and the second version is about 3x faster than the first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of interest, did you check which version is implemented in Mantid?
Can you add the math and explanation as a docstring?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of interest, did you check which version is implemented in Mantid?
I didn't understand what they did there before. But now I think I see what they do.
First, they work with the non-normalized position vector
They also project
But then they do slightly different, they return
In terms of efficiency this version and the one we have now are about the same.
It looks here like they have two scalar products but they don't because the projection on the plane orthogonal to
There's another difference in their implementation
const V3D scaleFactor = m_componentInfo.scaleFactor(index);
const double scaledPixelArea = m_pixelArea * scaleFactor[0] * scaleFactor[1];
return scaledPixelArea * cosAlpha / (l2 * l2);
This looks like what we have except the multiplications by the two scale-factors.
I don't know what the scaleFactor
s represent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can ignore scaleFactor
: This comes from the IDF description of the pixel shape, which may define a unit-cylinder, which is then scaled to the actual size. In NeXus there is not such scale, the actual size is stored directly.
src/esssans/normalization.py
Outdated
norm_pp = sc.norm(pixel_position) | ||
norm_ca = sc.norm(cylinder_axis) | ||
cosalpha = sc.sqrt(1 - sc.dot(pixel_position, cylinder_axis) / (norm_pp * norm_ca)) | ||
|
||
return 2 * radius * length * cosalpha / norm_pp**2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure how much this matters here, but not that you could make this faster by avoiding a lot of large intermediate results, using, e.g., +=
and *=
. It generally get harder to read though.
There are also some easy gains to be made, e.g., by putting parenthesis (and/or reordering) in the last line, so ensure we first combine all the 0-D variables, to ensure we have to create fewer pixel-dependent intermediate results.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it makes sense to group the 0-D variables, I'll fix that. But I don't think the performance improvement from using inplace operations will be worth the hit to readability in this case.
src/esssans/types.py
Outdated
"""Geometry of the detector from description in nexus file.""" | ||
|
||
|
||
class SampleReferenceTransform(sciline.Scope[RunType, sc.Variable], sc.Variable): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think people will find this name confusing, since many instruments may use "reference samples" to compare results to known results from, e.g., other facilities/instruments or.
Maybe LabFrameTransform
?
Test to compare solid angle computation in this PR with Mantid. import scipp as sc
import scippneutron as scn
from mantid.simpleapi import Load, SolidAngle, SetInstrumentParameter
ws = Load('SANS2D00063091.nxs')
radius = 0.00405 * sc.Unit('m')
length = 0.002033984375 * sc.Unit('m')
SetInstrumentParameter(ws, ParameterName='x-pixel-size', ParameterType='Number', Value=str((2 * radius).to(unit='mm').value))
SetInstrumentParameter(ws, ParameterName='y-pixel-size', ParameterType='Number', Value=str(length.to(unit='mm').value))
outWs = SolidAngle(ws, method='HorizontalTube')
dg = scn.from_mantid(outWs)
# Horizontal tubes
cylinder_axis = sc.scalar([1, 0, 0], dtype=sc.DType.vector3, unit='m')
pixel_position = dg['data'].coords['position'] - dg['data'].coords['sample_position']
# Compute solid angle like in the implementation
norm_pp = sc.norm(pixel_position)
norm_ca = sc.norm(cylinder_axis)
cosalpha = sc.sqrt(
1 - (sc.dot(pixel_position, cylinder_axis) / (norm_pp * norm_ca)) ** 2
)
solid_angle = (2 * radius * length) * cosalpha / norm_pp**2
# Get solid angle results from mantid
mantid_solid_angle = dg['data'].data['tof', 0]
# Remove nonexisting detector panels
solid_angle = solid_angle[mantid_solid_angle != sc.scalar(0)]
mantid_solid_angle = mantid_solid_angle[mantid_solid_angle != sc.scalar(0)]
assert sc.allclose(solid_angle, mantid_solid_angle) I'll add part of this data as a reference to unit test against. |
44d1988
to
41db7c9
Compare
See also mantidproject/mantid#36493 |
It would be useful to preserve this code as a unit test. Can you make one that runs only when Mantid can be imported (i.e., it would not run on CI, but we can run it locally). |
Specifically what part of it do you want preserved as a unit test? I'm asking because there already exists a unit test here that makes the comparison with the Mantid reference. It might be useful to preserve the code that created the reference though. |
That does not show how Mantid was used to compute the solid angle (and as we have seen there are multiple ways). So what I am referring to is the actual Python code that calls Mantid that you have in your code snippet. |
tests/normalization_test.py
Outdated
da_old = sc.io.load_hdf5(filename=fname) | ||
assert sc.identical(da, da_old) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about comparing to the actual computation, as you have done in the other test? You can move the common code into a helper function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what you mean. This test verifies the reference has not changed. The other test verifies our implementation (the actual computation) gives the same result as the reference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose it depends on your point of view. Given the history of how you arrived at this point it makes sense to compare the Mantid result to a reference file.
But I feel that in general comparing "result of software A" to "result of software B" is a "better" test. It is easier to understand out of context, and is less likely to break. For example, what if we later make some changes to the code in esssans
, change how the test of solid angle works.... but forget that we still have the old reference file. You have now a test that claims that everything is fine, but in fact it is not! This problem would not arise if you directly compared the results of the two implementations, without relying on some files that were written at some point in the past.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean. But if we compare with mantid directly instead of comparing with the reference file then we will only be able to run the test locally if mantid and scippneutron is installed in the same environment. I think that test will be run very rarely. With the reference file the test can be run in CI, making it much more likely we catch a regression.
How about this: In the test that recreates the mantid file we compare the results from both softwares directly and with the reference file, if any of them disagrees an error is reported. Maybe that was what you had in mind already?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I had in mind was:
- Your existing test, comparing the esssans result to the reference file.
- A test that compares the esssans result to the Mantid result.
If Mantid is missing, the second test will not run.
tests/normalization_test.py
Outdated
solid_angle = normalization.solid_angle( | ||
da, pixel_shape=pixel_shape, transform=transform | ||
) | ||
assert sc.allclose(solid_angle, mantid_solid_angle) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you set atol
and rtol
explicitly? The defaults may hide some relevant differences. In particular the default atol
may be way too large, since the solid angle results are quite small.
tests/normalization_test.py
Outdated
'vertices': sc.array( | ||
dims=['vertex'], | ||
values=[ | ||
[0, 0, 0], | ||
[R, 0, 0], | ||
[0, L, 0], | ||
], | ||
dtype=sc.DType.vector3, | ||
unit='m', | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may be looking for https://scipp.github.io/generated/functions/scipp.vectors.html, which simplifies the creation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While the file is small, I am not sure at all sure that adding test data to the repo is a good idea. Can we get it using pooch
, as done in other projects?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is it not a good idea? Seems more complicated to put it somewhere else and fetch it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it sets a precedence. How many "small" files is it ok to add? What is a "small" file?
Basically, I am worried about the future. Yes, it is more complicated now, but we will anyway need the setup for other files. And it already exists, basically, so there is not much extra work: https://github.com/scipp/esssans/blob/main/src/esssans/data.py.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for files used in tests having them in VC makes sense. Putting it elsewhere means that if a reference file changes
- we can not go back and see when and how a file/test changed,
- it breaks old versions of the repo unless the old versions of the files are also kept around.
I agree the line between too-large and small is fuzzy, but if we can add many similar sized files without significantly impacting users of the repo then IMO it is small enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but if we can add many similar sized files without significantly impacting users of the repo then IMO it is small enough.
Can we? How do you know it is not impacting users of the repo? Having binary files in Git is generally not a great idea, is it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we? How do you know it is not impacting users of the repo?
If the files are small compared to the size of the repository I think it is fair to say that it doesn't impact users.
Having binary files in Git is generally not a great idea, is it?
It's not good if the files are updated frequently. Is it also bad in other circumstances?
I'm of course open to storing the reference file in an external location if you think that is the better option.
But for the reasons I mentioned above that brings its own issues.
If you're concerned about potentially increasing the size of the repository (in case we update the reference file frequently, I don't think we will) I would suggest reducing the file size instead of storing it separately.
Including 4x fewer pixels in each dimension would reduce the file size considerably but I think it would still constitute a good test.
src/esssans/normalization.py
Outdated
@@ -64,6 +66,92 @@ def solid_angle_rectangular_approximation( | |||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the old function should be removed.
and :math:`\hat{r}` is the normalized pixel position vector. | ||
""" | ||
norm_pp = sc.norm(pixel_position) | ||
norm_ca = sc.norm(cylinder_axis) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this the same as length
? Why does the caller have to provide that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The direction of cylinder_axis
is required to compute
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But norm_ca
is the same as length
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I think I understand the function interface now. I found it a bit surprising, but you can leave it.
tests/normalization_test.py
Outdated
assert sc.allclose( | ||
solid_angle.data * 3, | ||
normalization.solid_angle_rectangular_approximation(da).data, | ||
da.data['tof', 0], solid_angle, atol=1e-10 * sc.Unit('dimensionless') | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So how large are the absolute and relative differences? How large are the values?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The values are approximately 1e-6 and the maximum of the absolute difference is machine precision (the number I get is ~1e-22).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you thus make atol
and rtol
as low as possible? This would make it clear that the results are really "the same".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why? Is a relative error of 1e-4 not sufficiently small? If we make the tolerances too low the test might break from a small change that doesn't meaningfully influence the result. The default rtol
in allclose is 1e-5. So this is already about the same accuracy we have in every other place where allclose
is used.
# Coordinates in pixel-local coordinate system | ||
# Bottom face center | ||
[0, 0, 0], | ||
# Bottom face edge | ||
[R, 0, 0], | ||
# Top face center | ||
[0, L, 0], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought Sans2d has horizontal tubes? This looks vertical to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the transform turns them horizontal.
tests/normalization_test.py
Outdated
da.data['tof', 0], solid_angle, atol=1e-10 * sc.Unit('dimensionless') | ||
da.data['tof', 0], solid_angle, atol=1e-11 * sc.Unit('dimensionless') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remember to also set rtol
(e.g., to zero)
R = 0.00405 | ||
L = 0.002033984375 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remember to remove to old values above (around line 40)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work 👍
ef4c3dd
to
9d96cef
Compare
Fixes #26.
Requires tests before review.