Skip to content

Fix parameter units being silently changed to SI units - #311

Open
henrikjacobsenfys wants to merge 6 commits into
developfrom
fix-parameter-units
Open

henrikjacobsenfys wants to merge 6 commits into
developfrom
fix-parameter-units

Conversation

@henrikjacobsenfys

@henrikjacobsenfys henrikjacobsenfys commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

This was written partly by Claude under my guidance.

Handles the issue mentioned here: #300

The solution is twofold:

  1. store the string users give when supplying a unit, and use that string rather than the scipp-generated one. This means users get the unit they asked for, not whichever unit combination the underlying library thinks it should be
  2. strip numbers only when the unit is purely a number, e.g. in the m/mm example.

For 1, it also handles e.g. when parameters are added or subtracted from other parameters.

@henrikjacobsenfys henrikjacobsenfys added [scope] bug Bug report or fix (major.minor.PATCH) [priority] medium Normal/default priority labels Sep 17, 2026
@rozyczko

rozyczko commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

The design is good and the implementation is careful. There is one inconsistency worth fixing before merge, one API contract change that needs to be documented and communicated, and a substantial amount of duplicated code that should be factored out

  1. The stored value depends on whether unit was passed as str or sc.Unit. Is this as expected?
    The "should I normalise?" decision is currently inferred from the type of the constructor argument! This looks wrong.
DescriptorNumber('s', 1.0, 'dm*m').value              # 1.0    , unit 'dm*m'   (scipp 0.1m^2)
DescriptorNumber('s', 1.0, sc.Unit('dm*m')).value     # 0.1    , unit 'm^2'

DescriptorNumber('s', 1.0, '1/meV^2').value           # 1.0    , unit '1/meV^2'
DescriptorNumber('s', 1.0, sc.Unit('1/meV^2')).value  # 3.9e+43, unit '1/J^2'
  1. .unit is no longer a key
    .unit is now only a display string. Two objects with the same unit can report different strings:
DescriptorNumber('a', 1, 'm**-1').unit == DescriptorNumber('b', 1, '1/m').unit   # False
DescriptorNumber('a', 1, 'angstrom').unit == DescriptorNumber('b', 1, 'A').unit  # False
# but in both cases a._scalar.unit == b._scalar.unit  -> True

I guess this is "as designed", but the dependent modules, like reflectometry, might be comparing .unit strings to test unit compatibility. This will now fail for particular spellings...

  1. The spelling methods are duplicated between the two descriptor classes

_remember_unit and _spelling_from_sources are identical apart from the type annotation style,
and also the two-line spelling check in each unit getter and the (state, _input_unit, _input_unit_parsed) tuple
handling. That is roughly 90 duplicated lines including docstrings.
Maybe create a mixin class?

  1. dead imports
    si_base_unit is imported at descriptor_number.py:26 and
    descriptor_array.py:28 and used in neither. It is only used inside units.py itself.

self._convert_unit(self._base_unit())
# Make sure no magnitude is left hiding inside the unit. This has to happen after
# super().__init__, so that subclasses which convert more than the scalar (such
# as Parameter, with its bounds) are fully constructed. It should not be recorded

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

super().__init__ here is DescriptorBase.__init__, which knows nothing about Parameter!!

fitter : Fitter
A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been
switched to ``AvailableMinimizers.Bumps``.
fitter : 'Fitter'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The quotes around Fitter come from some linting tool and are not valid numpydoc syntax.

try:
for i in range(count):
param = Parameter(name=f'thread_{thread_id}_param_{i}', value=float(i))
created.append(param)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

errr why is this change here? Leftover from testing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's because Claude found that this test passed for the wrong reason

"""
self._scalar, self._input_unit, self._input_unit_parsed = state

def _remember_unit(self, unit: str | sc.Unit | None) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

descriptor_array.py version of this method uses typing Union

def _remember_unit(self, unit: Union[str, sc.Unit, None]) -> None:

standardize on one

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we need to go through the entire library and update it - we still use Optional in some other places..

Comment thread src/easyscience/variable/units.py Outdated
if sc.Unit(target) != base_unit:
# The target does not survive a round trip, so it is not safe to use.
return None
except Exception:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bare exception is bad! Especially covering si_base_unit call, conversion to string and calling scipp's Unit. Please consider making the exception more specific.

@henrikjacobsenfys

henrikjacobsenfys commented Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

The design is good and the implementation is careful. There is one inconsistency worth fixing before merge, one API contract change that needs to be documented and communicated, and a substantial amount of duplicated code that should be factored out

  1. The stored value depends on whether unit was passed as str or sc.Unit. Is this as expected?
    The "should I normalise?" decision is currently inferred from the type of the constructor argument! This looks wrong.
DescriptorNumber('s', 1.0, 'dm*m').value              # 1.0    , unit 'dm*m'   (scipp 0.1m^2)
DescriptorNumber('s', 1.0, sc.Unit('dm*m')).value     # 0.1    , unit 'm^2'

DescriptorNumber('s', 1.0, '1/meV^2').value           # 1.0    , unit '1/meV^2'
DescriptorNumber('s', 1.0, sc.Unit('1/meV^2')).value  # 3.9e+43, unit '1/J^2'
  1. .unit is no longer a key
    .unit is now only a display string. Two objects with the same unit can report different strings:
DescriptorNumber('a', 1, 'm**-1').unit == DescriptorNumber('b', 1, '1/m').unit   # False
DescriptorNumber('a', 1, 'angstrom').unit == DescriptorNumber('b', 1, 'A').unit  # False
# but in both cases a._scalar.unit == b._scalar.unit  -> True

I guess this is "as designed", but the dependent modules, like reflectometry, might be comparing .unit strings to test unit compatibility. This will now fail for particular spellings...

  1. The spelling methods are duplicated between the two descriptor classes

_remember_unit and _spelling_from_sources are identical apart from the type annotation style, and also the two-line spelling check in each unit getter and the (state, _input_unit, _input_unit_parsed) tuple handling. That is roughly 90 duplicated lines including docstrings. Maybe create a mixin class?

  1. dead imports
    si_base_unit is imported at descriptor_number.py:26 and
    descriptor_array.py:28 and used in neither. It is only used inside units.py itself.

Yes, there's now explicitly a difference between using a string an a sc.Unit. A sc.Unit is stored in SI units underneath and has its own representation. There are some standard non-SI units included, see the discussion here: #300

A sc.Unit is the user saying "I want this unit, call it what you want".
A string is the user saying "THIS is how I want my unit represented. Not 1e-40 J^02 or whatever it corresponds to, I wrote "meV^-2" and that's what I want.

I'm not entirely sure what to do about "unit as a key" vs "unit as a display string". We can't really do both with just .unit. So we either choose that .unit stays a sc.Unit as it was and add some other way to see the unit in, well, the "correct" form, or it becomes a display string as it's suggested here, and we use .full_value.unit or something similar to get the underlying sc.Unit.

@henrikjacobsenfys

Copy link
Copy Markdown
Member Author

I don't know why it keeps failing the lint format. pixi run fix is green locally

This branch has not been deployed

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

Labels

[priority] medium Normal/default priority [scope] bug Bug report or fix (major.minor.PATCH)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants