Skip to content

Conversation

@klytje
Copy link
Contributor

@klytje klytje commented Jul 27, 2025

Description

This PR adds support for generating and fitting plugin models from the shape2sas model builder.

GUI

  • The to plugin model button in the main shape2sas window has been re-enabled and fully implemented.
  • The shape preview plot is now automatically updated upon making changes to the structure. I found the computational impact of this to be negligible, and it is a nice feature to have when building models.
  • The embedded log inside the constraint builder window is now fully functional, and reports common user errors in the constraints script (undefined vars, invalid constraints, python errors, ...)

Constraints

Constraints are now fully supported.

  • Constraints are essentially just a block of standard Python code that is copy/pasted into the body of the intensity calculations of the plugin model script.
  • Adding / removing fit parameters no longer clears the constraint text, but changes only the definition of the parameters variable while trying to preserve user changes to these. I'm not sure how robust the current code is, so this functionality should be extensively tested.

Several convenience variables have been added to simplify the user code:

  1. 'delta'-variants are defined for all shape parameters and may be used to easily constrain variables.
    dR1 = dR2 where dR2 is a fit parameter will propagate all changes in dR2 to dR1. Note how this is very different to writing R1 = R2 (also a valid constraint) which will fix their values to be identical. Using delta variables allows the initial values to be different while still tracking changes.

    A barbell (sphere 1, cyl 2, sphere 3) may therefore be constrained as:
    dR3 = dR1
    dL2 = -2*dR1
    where R1 is a fit parameter.

  2. 'COM' may be used to easily link all center-of-mass parameters (COMX, COMY, COMZ).
    dCOM2 = dCOM1 is automatically expanded to dCOMX2, dCOMY2, dCOMZ2 = dCOMX1, dCOMY1, dCOMZ1.

Most of the complexity of this PR comes from supporting delta parameters.

Efficiency

  • The intensity calculations may now be performed with AUSAXS, which is significantly faster (I measured 30x). I am not sure how feasible the fitting process is without this change.
    Edit: AUSAXS is not available in the installers, see libausaxs is not included in installers #3545.
  • To limit the plugin script size, delta variables are only defined for the shape parameters appearing in the constraint text. Though this is fine when using the GUI window to create the script, it means that manually modifying the script is not quite as simple.
  • The standard optimization algorithm (Levenberg-Marquardt) performs poorly for shape2sas fits, likely due to the function landscape variations arising from the limited sample size.

How Has This Been Tested?

This will require significant testing before being merged, though I don't have any good data to perform this testing with. The simulated data from shape2sas has too large error bars to be useful I think.

Todo

  • Documentation. Should I just write a short PDF that can be attached somewhere?
  • Testing
  • Clean up debug info from code
  • I feel like the fit result is a little disappointing. It would be nice to give better feedback on the optimized shape. I thought about generating a "fit result" file which could be imported back into the shape builder to easily visualize the optimized values, but that is not, I think, currently possible.

Review Checklist:

[if using the editor, use [x] in place of [ ] to check a box]

Documentation (check at least one)

  • There is nothing that needs documenting
  • Documentation changes are in this PR
  • There is an issue open for the documentation (link?)

Installers

  • There is a chance this will affect the installers, if so
    • Windows installer (GH artifact) has been tested (installed and worked)
    • MacOSX installer (GH artifact) has been tested (installed and worked)
    • Wheels installer (GH artifact) has been tested (installed and worked)

Licensing (untick if necessary)

  • The introduced changes comply with SasView license (BSD 3-Clause)

@klytje klytje force-pushed the shape2sas_fitting branch 2 times, most recently from 499f31e to f69e375 Compare August 22, 2025 08:47
@klytje
Copy link
Contributor Author

klytje commented Aug 22, 2025

It is not ideal that the automatic ruff fixes may break the build because it does not understand transitive imports.

@klytje klytje marked this pull request as ready for review August 22, 2025 11:31
@llimeht
Copy link
Contributor

llimeht commented Aug 25, 2025

It is not ideal that the automatic ruff fixes may break the build because it does not understand transitive imports.

I'm surprised ruff isn't complaining about the * imports as a matter of style, particularly without __all__ being explicit about what is to be imported.

This might be a good opportunity to get rid of the * imports in this new code? (And of course progressively remove them in old code)


#Generate subunits
for i in range(self.Number_of_subunits):
Npoints = int(self.Npoints * volume[i] / sum_vol)
Copy link
Contributor

Choose a reason for hiding this comment

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

Point density will be lower than expected if there is significant overlap between the subunits.

@@ -0,0 +1,10 @@
from dataclasses import dataclass
Copy link
Contributor

Choose a reason for hiding this comment

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

Here and elsewhere.

From PEP 8:

Modules should have short, all-lowercase names. Underscores can be used in the module name if it improves readability. Python packages should also have short, all-lowercase names, although the use of underscores is discouraged.

If you use uppercase letters in the module name you have to be sure that all the imports also use uppercase letters. If you are developing on a file system that doesn't distinguish upper and lower case you won't see the problem, but it may appear on Mac or Linux, depending on how the OS is configured.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This means I should change the filenames to be lowercase? I can do that, but it's not enforced in other areas of SasView.

z = np.random.uniform(-self.l / 2, self.l / 2, N)
d = np.sqrt(x**2 + y**2)
idx = np.where((d < self.R) & (d > self.r))
x_add, y_add, z_add = x[idx], y[idx], z[idx]
Copy link
Contributor

Choose a reason for hiding this comment

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

Rejection is inefficient, especially for narrow-walled pipes. It is better to use:

radius = np.sqrt(np.uniform(self.r**2, self.R**2, N))
... then follow the r == R case above ...

You can also use the gaussian trick discussed in Ellipsoid below. I'm not sure which is faster.


d2 = x**2 / self.a**2 + y**2 / self.b**2 + z**2 / self.c**2
idx = np.where(d2 < 1)
x_add, y_add, z_add = x[idx], y[idx], z[idx]
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of rejection you can use transformation. From math exchange here, the unit vector for a randomly generated gaussian will be uniform on the surface of a sphere, so normalize x = rand(n, 3) by sumsq(x) and scale by the cubed root of U(0,1). This gives random points in a sphere. For a triaxial ellipsoid, stretch by (a, b, c).

This gives

v = np.random.randn(Npoints, 3)
r = np.cbrt(np.random.rand(Npoints))
s = v*(r/np.sqrt(np.sum(v**2, axis=1)))[:,None]
x, y, z = s.T * np.array([[a,b,c]]).T

You can do something similar to sample from spherical shells, choosing r = cbrt(U(inner**3, outer**3, Npoints))

d = np.sqrt(x**2 + y**2 + z**2)

idx = np.where(d < self.R) #save points inside sphere
x_add,y_add,z_add = x[idx], y[idx], z[idx]
Copy link
Contributor

Choose a reason for hiding this comment

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

See comment in ellipsoid. Or just use ellipsoid with a=b=c=r.

z = np.random.uniform(-self.l / 2, self.l / 2, N)
d = np.sqrt(x**2 + y**2)
idx = np.where(d < self.R)
x_add,y_add,z_add = x[idx],y[idx],z[idx]
Copy link
Contributor

Choose a reason for hiding this comment

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

You can use polar coordinates, with r ~ sqrt(U[0, 1]), θ ~ U[0, 2π], or use the gaussian trick discussed in Ellipsoid below for the circular cross section. These should be more efficient then the rejection method.

try:
return globals()[key]
except KeyError:
raise ValueError(f"Class {key} not found in subunitClasses or global scope")
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking for a user supplied name in the global scope is a bit risky. You might be better off registering new shapes when they are imported, similar to the plugin models.


z_rot = (-self.x_add * np.sin(self.beta)
+ self.y_add * np.cos(self.beta) * np.sin(self.alpha)
+ self.z_add * np.cos(self.beta) * np.cos(self.alpha))
Copy link
Contributor

Choose a reason for hiding this comment

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

Be sure the application of the Euler angles is consistent with other places in SasView. For my own version shape2sas in the sasmodels explore directory here I'm using z-y-x convention for φ-θ-ψ, which is consistent with the convention I'm using for oriented particles in the sasmodels kernels.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The conventions differ. Shape2SAS uses the intrinsic ZYX convention, while you seem to be using ZYZ with exchanged angles (see the new test case here).

@klytje
Copy link
Contributor Author

klytje commented Aug 26, 2025

@pkienzle These are good points, but all of them are inherited from the existing shape2sas implementation. I just moved the model definitions out into separate files for clarity. I'll fix the bugs, but the efficiency concerns are perhaps better saved for another PR to keep this one focused on the extension to plugin models?

@DrPaulSharp
Copy link
Contributor

It is not ideal that the automatic ruff fixes may break the build because it does not understand transitive imports.

I'm surprised ruff isn't complaining about the * imports as a matter of style, particularly without __all__ being explicit about what is to be imported.

This might be a good opportunity to get rid of the * imports in this new code? (And of course progressively remove them in old code)

Ruff does complain about the * imports, the specific rule is F405, which is part of our ruleset. However, there is not an automatic fix available, so it is not blocked by the CI.

In my recent work on reducing the number of linting errors I have removed the vast majority of * imports from the SasView codebase (see #3539) and I would strongly recommend we do not merge code that introduces more of them. In this work I found a considerable number of problems due to unclear, indirect, or missing imports. For this reason, alongside the lack of overall clarity, I would strongly discourage the use of transitive imports.

The cause of the problems here was the use of the typing module for type hints, rather than the use of built-in types. This violates the ruff rule UP006, which is also part of our ruleset. This problem was automatically fixed by ruff in the CI and works for the file src/sas/sascalc/shape2sas/Typing.py but causes problems for transitive imports.

We now have an ADR proposal for type hints #3170. In this ADR it is explicitly stated that built-in types should be used instead of the typing module. Doing this would ensure that the issue encountered here would not reoccur.

@klytje
Copy link
Contributor Author

klytje commented Aug 26, 2025

Ruff does complain about the * imports, the specific rule is F405, which is part of our ruleset.

Personally I'd disagree with this rule - while wildcard imports from larger modules is obviously a terrible idea, I have here specifically designed the Typing and Models files for being imported in this manner. Explicitly listing every symbol in the import when we want everything is just unnecessary word salad. But my opinion on this is not strong enough that I'd reject this suggestion, so I can change it.

The cause of the problems here was the use of the typing module for type hints
...
We now have an ADR proposal for type hints https://github.com/orgs/SasView/discussions/3170. In this ADR it is explicitly stated that built-in types should be used instead of the typing module.

Again I'd like to point out that this is inherited from the existing shape2sas implementation. ruff broke existing code that I only moved somewhere else. I guess this will stop happening once the entire codebase has been updated to follow the new guidelines.

@butlerpd butlerpd self-requested a review August 26, 2025 13:37
@butlerpd
Copy link
Member

This was discussed at today's fortnightly call. It is noted that this also should be dealt with prior to the camp. It has been quite a while since the last discussion and there were questions as to whether all the issues had been addressed which nobody could remember off hand.

Note that @butlerpd has been tagged to review, and as someone tried to get this working before 6.1.0 is probably reasonably positioned to do so.

@klytje, it is noted that not fixing all the inherited stuff is not necessarily reasonable at this point. Other than that could you update whether there are any outstanding issues/discussions in this PR as far as you are concerned?

@DrPaulSharp
Copy link
Contributor

Alongside the comments from @butlerpd, @klytje could you please rebase this PR onto main, which will make reviewing more straightforward and ensure any potential merge conflicts are dealt with. Rebasing should also introduce the fix to the currently failing CI.

@klytje
Copy link
Contributor Author

klytje commented Oct 21, 2025

@butlerpd I (attempted to) address all of @pkienzle's comments except for those related to improving the sampling efficiency. I'd rather not touch any of that stuff without an extensive test suite to validate against (#3310), which is out of scope for this PR.

The only remaining issue is that of Euler rotation convention, where shape2sas is using a different (but more common) convention than the sasview kernel; see e.g. the new unit test (which should probably be removed; we just need to decide on which convention to follow).

We also have the issue of user testing, which is what has really been holding up this PR. I've done my best to make it as easy to use and robust as possible, but I need actual user feedback on these aspects.

@klytje
Copy link
Contributor Author

klytje commented Nov 14, 2025

While the plugin model generator contributed in this PR is fully functional, Shape2SAS itself cannot, in its current state, support iterative optimization due to the inherent randomness of the generated points. All but the Differential Evolution fitter completely fails at any sort of optimization. As there is no simple way to enforce the use of this algorithm, it has been decided that this PR should be merged, but in a disabled state. Once a future PR solves the underlying randomness issue, the plugin model functionality can easily be re-enabled.

Should users be interested in trying out the plugin models regardless of these issues, it can easily be enabled with the following two commands, to be run from Tools --> Python Shell/Editor:

import sas.qtgui.Utilities.ObjectLibrary as ol
ol.getObject("DataExplorer").parent.Shape2SASCalculator.plugin.setHidden(False)

Remember to manually enable the Differential Evolution algorithm in Fitting --> Fit Algorithms before it is used.

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.

6 participants