Skip to content

Conversation

@damskii9992
Copy link
Contributor

No description provided.

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

This pull request does not contain a valid label. Please add one of the following labels: ['[scope] bug', '[scope] enhancement', '[scope] documentation', '[scope] significant', '[scope] maintenance']

@damskii9992 damskii9992 added [scope] enhancement Adds/improves features (major.MINOR.patch) [priority] highest Urgent. Needs attention ASAP [area] base classes Changes to or creation of new base classes labels Nov 19, 2025
rozyczko and others added 2 commits November 19, 2025 14:34
Co-authored-by: Christian Dam Vedel <158568093+damskii9992@users.noreply.github.com>
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.

No dynamic parameter injections!
I think this is a start of something awesome.
Next stop - the interface or rather, the calculator factory...

This method is used by the serializer to determine which arguments are needed
by the constructor to deserialize the object.
"""
base_cls = getattr(self, '__old_class__', self.__class__)
Copy link
Member

Choose a reason for hiding this comment

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

__old_class__ is a part of the old design, set up in addLoggedProp which I think you wanted to avoid?

How about

spec = getfullargspec(self.__class__.__init__)

NewBase doesn't use dynamic class creation and the __old_class__ attribute is never set on NewBase instances...

It provides serialization capabilities as well as unique naming and display naming.
"""

def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str] = None):
Copy link
Member

Choose a reason for hiding this comment

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

I think NewBase should only deal with unique_names. Why do you consider display_name to be a part of it? It seems too unimportant - can be set by classes which inherit NewBase...

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 is why we should probably have an ADR discussion. To discuss what goes into the ground base class (this would be NewBase) and what goes into the derived classes :)
But I'm not against moving it into ModelBase.

else:
out_dict[key] = SerializerBase._convert_from_dict(value)
return out_dict

Copy link
Member

Choose a reason for hiding this comment

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

This implementation smells. Let's refactor it so it is easier to test.

Comment on lines 263 to 291
@staticmethod
def _deserialize_dict(in_dict: Dict[str, Any]) -> None:
"""
Deserialize a dictionary using from_dict for EasyScience objects and SerializerBase otherwise.
:param in_dict: dictionary to deserialize
:return: deserialized dictionary
"""
out_dict = {}
for key, value in in_dict.items():
if not key.startswith('@'):
if isinstance(value, dict) and "@module" in value and value["@module"].startswith("easy") and '@class' in value: # noqa: E501
module_name = value['@module']
class_name = value['@class']
try:
module = __import__(module_name, globals(), locals(), [class_name], 0)
except ImportError as e:
raise ImportError(f'Could not import module {module_name}') from e
if hasattr(module, class_name):
cls_ = getattr(module, class_name)
if hasattr(cls_, 'from_dict'):
out_dict[key] = cls_.from_dict(value)
else:
out_dict[key] = SerializerBase._convert_from_dict(value)
else:
raise ValueError(f'Class {class_name} not found in module {module_name}.')
else:
out_dict[key] = SerializerBase._convert_from_dict(value)
return out_dict

Copy link
Member

Choose a reason for hiding this comment

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

    @staticmethod
    def _deserialize_dict(in_dict: Dict[str, Any]) -> Dict[str, Any]:
        """
        Deserialize a dictionary using from_dict for ES objects and SerializerBase otherwise.
        This method processes constructor arguments, skipping metadata keys starting with '@'.
        
        :param in_dict: dictionary to deserialize
        :return: deserialized dictionary with constructor arguments
        """
        d = {
            key: SerializerBase._deserialize_value(value)
            for key, value in in_dict.items()
            if not key.startswith('@')
        }
        return d

    @staticmethod
    def _deserialize_value(value: Any) -> Any:
        """
        Deserialize a single value, using specialized handling for ES objects.
        
        :param value:
        :return: deserialized value
        """
        if not SerializerBase._is_serialized_easyscience_object(value):
            return SerializerBase._convert_from_dict(value)
            
        module_name = value['@module']
        class_name = value['@class']
        
        try:
            cls = SerializerBase._import_class(module_name, class_name)
            
            # Prefer from_dict() method for ES objects
            if hasattr(cls, 'from_dict'):
                return cls.from_dict(value)
            else:
                return SerializerBase._convert_from_dict(value)
                
        except (ImportError, ValueError) as e:
            # Fallback to generic deserialization if class-specific fails
            return SerializerBase._convert_from_dict(value)

    @staticmethod
    def _is_serialized_easyscience_object(value: Any) -> bool:
        """
        Check if a value represents a serialized ES object.
        
        :param value: 
        :return: True if this is a serialized ES object
        """
        return (
            isinstance(value, dict) 
            and "@module" in value 
            and value["@module"].startswith("easy") 
            and '@class' in value
        )

    @staticmethod
    def _import_class(module_name: str, class_name: str):
        """
        Import a class from a module name and class name.
        
        :param module_name: name of the module
        :param class_name: name of the class
        :return: the imported class
        :raises ImportError: if module cannot be imported
        :raises ValueError: if class is not found in module
        """
        try:
            module = __import__(module_name, globals(), locals(), [class_name], 0)
        except ImportError as e:
            raise ImportError(f'Could not import module {module_name}') from e
            
        if not hasattr(module, class_name):
            raise ValueError(f'Class {class_name} not found in module {module_name}.')
            
        return getattr(module, class_name)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, this is much better. I took inspiration from the _convert_from_dict method, which is why it ended up not being properly factorised.
The _is_serialized_easyscience_object method might be taking it too far though, being a single logical check.


class ModelBase(NewBase):
"""
This is the base class for all model classes in EasyScience.
Copy link
Member

Choose a reason for hiding this comment

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

ModelBase is where we should add display_name, I think?

Comment on lines 62 to 64
params = []
for attr_name in dir(self):
attr = getattr(self, attr_name)
Copy link
Member

Choose a reason for hiding this comment

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

return [param for param in self.get_fit_parameters() if param.fixed]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I considered this approach too. It's much simpler, for the other method too. The reason I didn't choose it was for performance. This implementation only runs through the list of attributes once, your suggestion runs through the produced list again to re-filter it. The performance gain is probably so minimal it isn't worth it though :/

Comment on lines 71 to 82
def get_free_parameters(self) -> List[Parameter]:
"""
Get all parameters which are currently free to be fitted as a list.
:return: List of `Parameter` objects.
"""
params = []
for attr_name in dir(self):
attr = getattr(self, attr_name)
if isinstance(attr, Parameter) and not attr.fixed and attr.independent:
params.append(attr)
return params
Copy link
Member

Choose a reason for hiding this comment

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

return [param for param in self.get_fit_parameters() if not param.fixed]


@classmethod
def from_dict(cls, obj_dict: Dict[str, Any]) -> None:
"""
Copy link
Member

Choose a reason for hiding this comment

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

multiple conditionals are icky. I'd like to rewrite this to be more modular, but can't be arsed now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I fixed it. Using the _is_serialized_easyscience_object method you refactored in the SerializerBase class. Clearly it was useful to refactor it even though it was only a simple logical check.

@codecov
Copy link

codecov bot commented Nov 20, 2025

Codecov Report

❌ Patch coverage is 88.02589% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.82%. Comparing base (a3a9bcb) to head (f8b0817).
⚠️ Report is 3 commits behind head on develop.

Files with missing lines Patch % Lines
...yscience/variable/parameter_dependency_resolver.py 63.41% 25 Missing and 5 partials ⚠️
src/easyscience/variable/parameter.py 92.45% 2 Missing and 2 partials ⚠️
src/easyscience/io/serializer_base.py 93.93% 1 Missing and 1 partial ⚠️
src/easyscience/variable/descriptor_number.py 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #159      +/-   ##
===========================================
+ Coverage    73.06%   80.82%   +7.76%     
===========================================
  Files           47       50       +3     
  Lines         3943     4246     +303     
  Branches       671      739      +68     
===========================================
+ Hits          2881     3432     +551     
+ Misses         868      629     -239     
+ Partials       194      185       -9     
Flag Coverage Δ
unittests 80.82% <88.02%> (?)

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

Files with missing lines Coverage Δ
src/easyscience/base_classes/__init__.py 100.00% <100.00%> (ø)
src/easyscience/base_classes/based_base.py 81.65% <100.00%> (+1.45%) ⬆️
src/easyscience/base_classes/model_base.py 100.00% <100.00%> (ø)
src/easyscience/base_classes/new_base.py 100.00% <100.00%> (ø)
src/easyscience/global_object/map.py 89.41% <100.00%> (+37.31%) ⬆️
src/easyscience/variable/descriptor_number.py 89.57% <88.88%> (+0.65%) ⬆️
src/easyscience/io/serializer_base.py 84.53% <93.93%> (+30.49%) ⬆️
src/easyscience/variable/parameter.py 90.49% <92.45%> (+0.13%) ⬆️
...yscience/variable/parameter_dependency_resolver.py 63.41% <63.41%> (ø)

... and 4 files with indirect coverage changes


@unique_name.setter
def unique_name(self, new_unique_name: str):
"""Set a new unique name for the object. The old name is still kept in the map.
Copy link
Member

Choose a reason for hiding this comment

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

Why is the old name still kept in the map? Just curious; I haven't taken the time to even try to understand how the map works.

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 was decided back in #16 under Andrews comment and Andreas reply. Thank god that we have ADR's and paper trails to follow now to remember these things ;)

params += attr.get_all_parameters()
return params

def get_fit_parameters(self) -> List[Parameter]:
Copy link
Member

Choose a reason for hiding this comment

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

I'm pretty sure the fitter uses this method to choose which Parameters to fit, so we need to be a bit careful here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That is exactly why I named this method like this. I wanted to name it get_fittable_parameters, but the Fitter expects this method name :/
I will obviously make unit tests and integration tests to check that this implementation works with the Fitter :)

@damskii9992 damskii9992 mentioned this pull request Nov 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[area] base classes Changes to or creation of new base classes [priority] highest Urgent. Needs attention ASAP [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants