Skip to content

Parametrized physical properties - #1601

Open
jcapriot wants to merge 81 commits into
simpeg:mainfrom
jcapriot:parametrize
Open

jcapriot wants to merge 81 commits into
simpeg:mainfrom
jcapriot:parametrize

Conversation

@jcapriot

@jcapriot jcapriot commented Jan 28, 2025 •

Copy link
Copy Markdown
Member

Summary

Implements a parametrized interface to PhysicalProperties

PR Checklist

  • If this is a work in progress PR, set as a Draft PR
  • Linted my code according to the style guides.
  • Added tests to verify changes to the code.
  • Added necessary documentation to any new functions/classes following the
    expect style.
  • Marked as ready for review (if this is was a draft PR), and converted
    to a Pull Request
  • Tagged @simpeg/simpeg-developers when ready for review.

Reference issue

What does this implement/fix?

Additional information

Continuing on from #1593

@codecov

codecov Bot commented Jan 28, 2025 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.98088% with 105 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.06%. Comparing base (137a372) to head (756b4d7).

Files with missing lines Patch % Lines
simpeg/props.py 90.34% 33 Missing and 25 partials ⚠️
...g/electromagnetics/natural_source/simulation_1d.py 52.00% 4 Missing and 8 partials ⚠️
simpeg/base/pde_simulation.py 88.23% 0 Missing and 8 partials ⚠️
...netics/static/spectral_induced_polarization/run.py 0.00% 6 Missing ⚠️
...agnetics/static/induced_polarization/simulation.py 81.81% 2 Missing and 2 partials ⚠️
tests/base/props/test_physical_properties.py 99.42% 4 Missing ⚠️
simpeg/electromagnetics/base_1d.py 93.18% 0 Missing and 3 partials ⚠️
...electromagnetics/frequency_domain/simulation_1d.py 75.00% 0 Missing and 1 partial ⚠️
...impeg/electromagnetics/frequency_domain/sources.py 66.66% 0 Missing and 1 partial ⚠️
.../electromagnetics/static/resistivity/simulation.py 66.66% 0 Missing and 1 partial ⚠️
... and 7 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1601      +/-   ##
==========================================
+ Coverage   86.82%   87.06%   +0.23%     
==========================================
  Files         436      439       +3     
  Lines       57838    58995    +1157     
  Branches     5457     5568     +111     
==========================================
+ Hits        50217    51363    +1146     
- Misses       6186     6188       +2     
- Partials     1435     1444       +9     

☔ View full report in Codecov by Harness.
📢 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.

@jcapriot
jcapriot marked this pull request as ready for review January 28, 2025 13:51
@jcapriot

jcapriot commented Feb 2, 2025 •

Copy link
Copy Markdown
Member Author

I think the new interface is coming together, here is a summary of the changes:

New interface

On the constructor you can do:

# Initialize with a given value
sim = Simulation(sigma=[1,2,3])

# or initialize as a parametrization (mappings for now)
sim = Simulation(sigma=mapping)

Or changing it after creating a simulation:

# setting the sigma property as a value
sim.sigma = [1, 2, 3]

# parametrizing sigma
sim.parametrize(sigma=mapping)

When properties are parametrized they are dependent on the simulation's model attribute, and derivatives w.r.t. that property are then enabled for inversion. These physical properties also require a model and will error if the simulation doesn't have one assigned.

P.S. A non-invertible property cannot be parametrized

Added methods

These public methods are added to the HasModel class to work together with PhysicalProperties

  • HasModel.parametrize(**kwargs), the aforementioned item.

  • HasModel.parametrizations a ParametrizationList storing the mapping used to parametrize each property

    • Accessible with attributes named after the PhysicalProperty class.
      map1 = ExpMap()
      sim.simulation(sigma=mapping)
      # And it can be retrieved using 
      maps1 == sim.parametrizations.sigma
      
  • HasModel.physical_properties A class method to return a dictionary of attr:PhysicalProperty of all the physical properties defined on a class. There wasn't anything specifically with this functionality before on a class, my hope is that it provides an easy way to see which PhysicalProperties are possible on a simulation.

  • HasModel.invertible_properties A class method to return a dictionary of attr:PhysicalProperty of all the invertible physical properties defined on a class. This is basically making the previously private class attribute HasModel._all_map_names a public method. The hope is to make it more transparent which physical properties are invertible on a class.

  • HasModel.is_parametrized(self, attr) Returns a bool indicating if a PhysicalProperty (or it's reciprocal) exists in HasModel.parametrizations. (A note: This is a helper to enable us to check if a derivative is necessary, previously we would do this by checking sim.sigmaMap is None or something similar, this is just a cleaner interface and it also allows us to override it in a subclass.)

Derivatives

Getting the derivative of a property is now handled through a function, as opposed to a property, allowing us to (eventually) pass through multiplication vectors to the parametrizations.

  • HasModel._prop_deriv(self, attr)

Unless anyone has strong feelings otherwise, I'm in favor of keeping this private to keep the public API cleaner.

I'll add more details in the future

@jcapriot jcapriot mentioned this pull request Feb 5, 2025
6 tasks
@jcapriot
jcapriot marked this pull request as draft February 5, 2025 20:51
@jcapriot

jcapriot commented Feb 5, 2025 •

Copy link
Copy Markdown
Member Author

These are things that would be deprecated or removed with this PR.

Deprecations

On initialization

  • Simulation(sigmaMap=mapping)` would be deprecated (in favor of the above interface.

Attribute Assignment:

  • sim.sigmaMap = mapping would go to sim.parametrize(sigma=mapping)

Attribute Access:

  • mapping = sim.sigmaMap would go to sim.parametrizations.sigma
  • deriv = sim.sigmaDeriv is no longer an attribute, and is instead a function sim._prop_deriv("sigma", m, v=None).
    This is basically a helper for doing sim.parametrizations.sigma.deriv(m, v), that also accounts for reciprocals.

Removals

  • simpeg.props.Mapping, simpeg.props.Derivative, simpeg.props.Invertible, simpeg.props.Reciprocal. These items were used together with the MetaClass on HasModel to create all of the property attributes that we previously had. As these were used internally and will not work with the new interface, they will throw a SyntaxError if someone attempts to use them.

@lheagy lheagy left a comment

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.

Thanks for bringing this up again @jcapriot and sorry it took me so long to get back to this. I think this idea can work. I have just done a light pass to ask a few questions. Would you mind giving a demo again at an upcoming SimPEG meeting? It would be valuable to share this idea again with the group and get input from others. I think this could simplify things for users, but would appreciate chatting through implications for developers and people working to understand the code


# simpeg problem and survey
prob = simulation.LinearSimulation(mesh, G=G, model_map=maps.IdentityMap())
prob = simulation.LinearSimulation(G=G, model_map=maps.IdentityMap())

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.

Is this change intended to be in this PR? I am not sure it is in this scope? or maybe I am missing something

noise_floor = 0.0

prob1 = simulation.LinearSimulation(mesh, G=G, model_map=wires.m1)
prob1 = simulation.LinearSimulation(G=G, model_map=wires.m1)

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.

similarly here, are these meant to be a part of this pr? it seems a bit different in scope?

Comment thread simpeg/props.py Outdated
val = recip.default
# If it wasn't None, try to invert it.
if val is not None:
val = 1 / val

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.

do we need a check that this is a scalar (or at least isotropic)? This wouldn't work if anisotropic

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.

Can update it to be anisotropic aware. At most it would be a block operation of inverting 3x3 matrices.

Comment thread simpeg/props.py
self.fset(obj, value)
else:
self._fset(obj, value)
obj._remove_parametrization(self.name)

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.

if I am following correctly, this would be hit if someone swapped out a mapping with a value or another mapping, is that right? If so, we might want to throw a warning? Changing something from an invertible parameter to not has some potentially involved implications

Comment thread simpeg/props.py Outdated
Comment on lines +433 to +440
warnings.warn(
f"Setting both `{prop1.name}` and `{prop2.name}` to None for `{type(self).__name__}`'s required "
f"physical properties is deprecated behavior. This message will be changed to an error in simpeg "
f"version X.X",
FutureWarning,
stacklevel=3,
# f"`{type(self).__name__}` requires one of `{prop1.name}` or `{prop2.name}`"
)

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.

A question on warning / error handling, will the user first see that they need to change sigmaMap=mapping to sigma=mapping? If not, this warning might be confusing

Comment thread simpeg/props.py Outdated
Comment on lines +748 to +750
warnings.warn(
f"Setting `{cls_name}.{old_map}` directly is deprecated. Instead register a parametrization with `{cls_name}.parametrize('{new_name}')`",
FutureWarning,

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.

Can we also add that the user can set the mapping in the class instantiation via prop=mapping. I think that is the behaviour that we will want to encourage (I suspect most people will find the transition from sigmaMap=mapping --> sigma=mapping much easier than going through the class.parameterize ..... approach

@santisoler santisoler added this to the 0.26.0 milestone Oct 15, 2025

@santisoler santisoler left a comment

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.

I like the ideas in this PR! I think I mentioned this several times, but I would also like to merge the physical property and their mapping into a single argument in simulations.

I also like how the PR groups together all things related to a physical property into a single object (instead of having sigma, sigmaMap, sigmaDeriv, for example).

Another thing I quite like is how easy it would be to know the shape of the expected model directly from the parametrization list. This would be a great feature to have when using SimPEG simulations in the new inversion framework, where the DataMisfit tries to get the n_params property from the simulation itself. Currently that's a bit of a challenge that varies across different simulations.

I'm leaving a few comments below so we can move forward with this.

Error messages while setting physical property as mapping

I would improve the error message when trying to set a physical property as a mapping. For example, if I run simulation.sigma = conductivity_mapping I get the following error:

TypeError: '`Simulation3DNodal.sigma`' must be array_like with data type of <class 'float'>, got <class 'simpeg.maps._injection.InjectActiveCells'>

Maybe we could make this error clearer, and point users on using the new simulation.parametrize(sigma=conductivity_model) interface.

Making physical_properties and invertible_properties properties?

WOuld it be possible to make the physical_properties and invertible_properties as (Python) properties of the class, so we could access them through:

simulation.physical_properties

Instead of having to call them (as methods)?

Updating deprecation admonitions

I noticed a few admonitions in docs warning users about deprecations of the old properties. Most of them have old versions of SimPEG. I would update them to the next release: v0.26.0.

Connecting exclusive physical properties?

I think I briefly mentioned this idea in the last meeting, but I'll try to expand more on it here.

I was wondering if it would be best to group exclusive physical properties (exclusive in the sense of XOR, i.e. one can either pass one XOR the other) together in a single PhysicalProperty.

Just brainstorming, but something like:

sigma = props.PhysicalProperty(
    "Electrical conductivity (S/m)",
    reciprocal=props.PhysicalProperty( "Electrical resistivity (Ohm m)"),
    shape=[(), (1,), ("mesh.n_cells",)],
)

The HasModel can inject the two properties and the setters for each one of them. This way we could avoid having to handle the two of them separately in a few different places.

I don't intend to change the public interface, just refactor some of the internals so two reciprocal physical properties are naturally connected to each other.

Is it a bad idea?

Comment thread simpeg/props.py Resolved
Comment thread simpeg/props.py
return newcls


class ParametrizationList:

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.

On this note, I was initially going to suggest to make this a child of collections.UserDict to inherit the interface of dictionaries. But maybe it will require even more work to ensure that the list is "read only".

Comment thread simpeg/props.py Outdated
if key in self.__slots__:
super().__setattr__(key, value)
elif key in self._fields:
raise AttributeError(f"'Cannot set attribute '{key}'") from 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.

We could potentially extend this error message, explaining that the ParametrizationList is meant to be read-only and we cannot modify the existing attribute.

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.

That's a good idea.

Comment thread simpeg/props.py

"""
for attr, parametrization in kwargs.items():
if not isinstance(parametrization, maps.IdentityMap):

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.

I'm wondering if situations like these ones might be a good opportunity to user typing.Protocols decorated with the @runtime_checkable.

I understand that we are currently using the IdentityMap as the base class for every map (something that it's weird, since all the other maps are not the identity one). So we could potentially make the distinction here that we expect the parametrization to be a mapping but not necessarily an IdentityMap.

@jcapriot jcapriot Sep 6, 2026 •

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 do agree it would be good to add them as a protocol check. I put the initials of it here, but it would take several changes in ‘maps.py’ to properly support it.

Comment thread simpeg/props.py Resolved
Comment thread simpeg/props.py Resolved
Comment thread simpeg/base/pde_simulation.py Outdated
Comment on lines +637 to +687
@sigma.setter
def sigma(self, value):
if value is not None:
prop = type(self).sigma
value = validate_ndarray_with_shape(
f"`{type(self).__name__}.sigma`",
value,
shape=[(), (1,), (self.mesh.n_cells,)],
dtype=float,
)
setattr(self, prop.private_name, value)

for mat in self._clear_on_sigma_update:
if hasattr(self, mat):
delattr(self, mat)

@sigma.deleter
def sigma(self):
prop = type(self).sigma
if hasattr(self, prop.private_name):
delattr(self, prop.private_name)

for mat in self._clear_on_sigma_update:
if hasattr(self, mat):
delattr(self, mat)

@rho.setter
def rho(self, value):
if value is not None:
prop = type(self).rho
value = validate_ndarray_with_shape(
f"`{type(self).__name__}.rho`",
value,
shape=[(), (1,), (self.mesh.n_cells,)],
dtype=float,
)
setattr(self, prop.private_name, value)

for mat in self._clear_on_rho_update:
if hasattr(self, mat):
delattr(self, mat)

@rho.deleter
def rho(self):
prop = type(self).rho
if hasattr(self, prop.private_name):
delattr(self, prop.private_name)

for mat in self._clear_on_rho_update:
if hasattr(self, mat):
delattr(self, mat)

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.

Are we planning to keep these properties implemented in this way? Or should HasModel add these to the class based on the PhysicalProperty?

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.

Right now, it was mostly just a mechanism to invalidate the cache for those properties. I think it would best to update the caching mechanism on the HasModel class, but likely better for a separate PR.

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.

Handling the shape can likely be done better by defining where properties exist ("mesh.cell_centers", "mesh.faces", etc.) once we add that capability..

The metaclass now translates the deprecated xMap arguments (and raises if both x and xMap are given), but a subclass that forwards model= or xMap= up the super() chain used to hit object.__init__. HasModel.__init__ accepts both and the metaclass applies them after construction, so subclasses can keep setting their properties in any order. The model is only set once the constructor has finished, so reading self.model inside an __init__ gives None. The warnings about the constructor arguments and the xMap setter are now attributed to the caller by skipping only the frames in props.py, which also lets the pytest filters see simpeg using its own deprecated interface.
With rho parametrized, sim.sigmaMap = None used to clear rho's mapping too. Main leaves it alone, so do the same.
parametrize(sigma=A, rho=B) in one call raised nothing and silently kept only rho, it is a TypeError now. The class definition check no longer fires for a mixin that defines just one half of a pair, and it catches a reciprocal that does not point back. Giving neither of a pair also warns once now, instead of twice.
Keeps HasModel.model.fset(self, value) returning whether the model changed, as it did on main.
The 1D EM simulations passed h=None to a required property and warned on every default construction (h now defaults to None). Simulation3DDifferential defaulted mu before resolving the mu/mui pair, so mui= raised. The self potential simulation raises the ValueErrors from main again when neither or both of sigma and rho are given.
Sort the parametrize lists that came from sets (so collection order does not change between xdist workers), check flattened diagonal values on 2D and 3D meshes, and check the message of the removed PhysicalProperty arguments. Also add a check that every constructor that lost an xMap argument is keyword only, and that property defaults match the constructor defaults.
…recated map

When a subclass adds sigmaMap= in its own super().__init__ call, the map is stashed and applied after construction. The intermediate __init__ still ran _init_recip_properties(sigma=None, rho=None), which warned about both being None, and a sigma value given next to the map was silently replaced. Both now look at the stashed parametrizations, so only the sigmaMap deprecation warns and a value plus a map raises the same TypeError as the top level path. Also note in the release notes that a subclass naming an xMap argument in its signature receives None for it.
Name the class that actually holds the shadowing attribute in the split reciprocal error, use a class with six properties to check the choices in the no parametrizations error are sorted (with three it passed on unsorted code about one run in six), and drop the xMap = None note from the breaking changes (it behaves as before).
@jcapriot

jcapriot commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Pushed another round of fixes. The one that mattered most is that MetaSimulation (and the multiprocessing and dask versions) returned a stale getJtJdiag after the model changed, that's fixed and tested.

Also in there: flattened anisotropic values, IP/SIP construction warnings, parametrize() failing halfway, Sim(sigma=mapping, model=m), and subclasses that forward sigmaMap= up the super() chain. simpeg also no longer uses its own xMap/xDeriv, and the pytest filters fail if it does again.

I put the deprecation at 0.27.0 and the removal at 0.29.0, with a draft 0.27.0-notes.rst. The xMap= uses in the examples can go in a follow-up.

I think I'll be happy with it in this state for merging to main and trying it. If anything it will just be even further edge cases, but I think I was able to catch most of them...

PhysicalProperty takes components (an integer, or "dim" for the dimension of the mesh) for a property that has a vector at every location, like the remanent polarization in the magnetics differential simulation. The values and mapping outputs are checked against the mesh, and a value with a shape of (n, components) is stored flat in the order discretize uses. rem now has to have a length of 3 * n_cells (it accepted any length before, and rejected (n, 3) arrays).
The warning told people to call _prop_deriv, which is private. It now points at parametrizations.x.deriv(model) (and the docstring and release notes mention composing with ReciprocalMap when only the reciprocal is parametrized). The pytest filters for the warning match the new message.
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.

3 participants