Skip to content

Conversation

@henrikjacobsenfys
Copy link
Member

Implements convolution of SampleModels and Components with each other, including detailed balancing. The code is based on the SampleModel branch, not develop.

I have very extensive tests of the convolutions. Many of them were written a long time ago, and so they can probably be improved. However, before doing that, a review of the main code would be very useful.

@henrikjacobsenfys henrikjacobsenfys added this to the First release milestone Oct 31, 2025
@henrikjacobsenfys henrikjacobsenfys added [scope] enhancement Adds/improves features (major.MINOR.patch) [priority] medium Normal/default priority labels Oct 31, 2025
@codecov
Copy link

codecov bot commented Oct 31, 2025

Codecov Report

❌ Patch coverage is 94.93243% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.89%. Comparing base (01b9490) to head (176ce80).

Files with missing lines Patch % Lines
src/easydynamics/utils/convolution.py 93.51% 12 Missing ⚠️
src/easydynamics/sample_model/sample_model.py 97.22% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop      #59      +/-   ##
===========================================
- Coverage    96.60%   95.89%   -0.71%     
===========================================
  Files           12       14       +2     
  Lines          412      707     +295     
===========================================
+ Hits           398      678     +280     
- Misses          14       29      +15     
Flag Coverage Δ
unittests 95.89% <94.93%> (-0.71%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Member

@rozyczko rozyczko left a comment

Choose a reason for hiding this comment

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

A few general comments to a very well written module


# Handle offset
if offset is None:
off = 0.0
Copy link
Member

Choose a reason for hiding this comment

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

really bad name for offset - please consider changing to a meaningful name like _offset or rename the input offset to x_offset

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point! I've renamed it to offset_float, since it's the offset as a float.

)

# Handle temperature
T = None
Copy link
Member

Choose a reason for hiding this comment

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

Avoid single letter variable names unless used in tight loops/list comprehension.

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 know why it was even there - I put a decent amount of time into making sure I could pass Parameters or floats directly to _detailed_balance_factor, so I've just deleted it. Thanks!

and isinstance(resolution_component, Gaussian)
):
if isinstance(sample_component, Gaussian):
G, L = sample_component, resolution_component
Copy link
Member

Choose a reason for hiding this comment

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

Avoid single letter variables here as well, but in this case it's a bit more allowable, since all the usage is within several lines of code.

) -> List[DeltaFunction]:
"""Return a list of DeltaFunction instances contained in `model`.

Args:
Copy link
Member

Choose a reason for hiding this comment

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

All other args: in docstring are lower case. Please be consistent :)

Comment on lines 196 to 213
if upsample_factor == 0:
# Check if the array is uniformly spaced.
x_diff = np.diff(x)
is_uniform = np.allclose(x_diff, x_diff[0])
if not is_uniform:
raise ValueError(
"Input array `x` must be uniformly spaced if upsample_factor = 0."
)
x_dense = x
else:
# Create an extended and upsampled x grid
x_min, x_max = x.min(), x.max()
span = x_max - x_min
extra = extension_factor * span
extended_min = x_min - extra
extended_max = x_max + extra
num_points = len(x) * upsample_factor
x_dense = np.linspace(extended_min, extended_max, num_points)
Copy link
Member

Choose a reason for hiding this comment

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

This is a pretty complex grid routine. Please consider extracting it to a helper function, where it can be properly unit tested.


Args:
x : np.ndarray
1D array of x values where the convolution is evaluated.
Copy link
Member

Choose a reason for hiding this comment

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

The docstring doesn't clearly explain:

  • What physical quantities x represents (energy transfer?)
  • Unit consistency requirements
  • What happens if units don't match

Maybe add some examples? Just something to consider. Maybe imaging users know this by heart :)

    >>> x = np.linspace(-10, 10, 1000)  # Energy transfer in meV
    >>> sample = Gaussian(center=0, width=1.0, area=1.0, unit="meV")
    >>> resolution = Gaussian(center=0, width=0.5, area=1.0, unit="meV")
    >>> result = convolution(x, sample, resolution)

) -> Tuple[bool, np.ndarray]:
"""
Attempt an analytic convolution for component pair (sample_component, resolution_component).
Returns (True, contribution) if handled, else (False, zeros).
Copy link
Member

Choose a reason for hiding this comment

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

You might want to consider improving the return description

Returns:
    Tuple[bool, np.ndarray]: 
        - bool: True if analytical convolution was computed, False otherwise
        - np.ndarray: The convolution result if computed, or zeros if not handled

The evaluated Voigt profile values at x.
"""

return area * voigt_profile(x - center, g_width, l_width)
Copy link
Member

Choose a reason for hiding this comment

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

scipy's voigt_profile is normalized, so multiplying by area (which is the product of the two component areas) should work. However, this needs verification with unit tests. Please add some.

if isinstance(resolution_component, DeltaFunction):
return True, resolution_component.area.value * sample_component.evaluate(
x - resolution_component.center.value - off
)
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 this be x - off - sample_component.center.value for consistency? The same, but looks more proper maybe.
does offset shift the sample model, resolution model, or the convolution result? Would be good to mention 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.

You're right, offset needs to be explained better. It shifts the convolution result. The center should be the sum of the centers of the sample, resolution and offset. In this case, the sample center is included in the sample_component.evaluate() call, so it needs to be shifted by the other two terms (resolution and offset)

Comment on lines 218 to 221
if len(x_dense) % 2 == 0:
x_even_length_offset = -0.5 * dx
else:
x_even_length_offset = 0.0
Copy link
Member

Choose a reason for hiding this comment

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

This looks like a subtle correction - can you add some comment on why even-length arrays need this correction? Is it correct for all symmetric/asymmetric cases?

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 is indeed subtle - I've added an explanation:
# The convolution of two arrays of length N is of length 2N-1. When using 'same' mode, only the central N points are kept,
# so the output has the same length as the input.
# However, if N is even, the center falls between two points, leading to a half-bin offset.
# For example, if N=4, the convolution has length 7, and when we select the 4 central points we either get
# indices [2,3,4,5] or [1,2,3,4], both of which are offset by 0.5*dx from the true center at index 3.5.

@@ -0,0 +1,846 @@
import numpy as np
Copy link
Member

Choose a reason for hiding this comment

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

Potentially needs additional tests for:

  • Offset with detailed balance
  • Non-uniform spacing detection accuracy
  • Edge cases (empty models, zero widths)
  • Voigt area conservation
  • Very wide/narrow peaks triggering warnings

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, looking at the code, I should probably redo the whole test file. The vast majority of the tests are not actually unit tests. For example, I'm testing each case in _try_analytic_pair indirectly by testing _analytical_convolution.

@henrikjacobsenfys
Copy link
Member Author

Okay, the main file could use another round of reviews before I clean up the mess of unit tests :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[priority] medium Normal/default priority Resolution [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants