5

I am trying to write a simulation class that can easily be extended. For this I'd like to use something similar to a property, but that also provides an update method that could be implemented differently for different use cases:

class Quantity(object):
    
    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value
    
    def update(self, parent):
        """here the quantity should be updated using also values from
        MySimulation, e.g. adding `MySimulation.increment`, but I don't
        know how to link to the parent simulation."""

        
class MySimulation(object):
    "this default simulation has only density"
    density = Quantity()
    increment = 1
    
    def __init__(self, value):
        self.density = value
    
    def update(self):
        """this one does not work because self.density returns value
        which is a numpy array in the example and thus we cannot access
        the update method"""
        self.density.update(self)

The default simulation could the be used like this:

sim = MySimulation(np.arange(5))

# we can get the values like this
print(sim.density)
> [0, 1, 2, 3, 4]

# we can call update and all quantities should update
sim.update()  # <- this one is not possible

I would like to write it in such a way such that the simulation can be extended in any user-defined way, for example adding another quantity that is updated differently:

class Temperature(Quantity):
    def update(self, parent):
        "here we define how to update a temperature"


class MySimulation2(MySimulation):
    "an improved simulation that also evolves temperature"
    temperature = Temperature()
    
    def __init__(self, density_value, temperature_value):
        super().__init__(density_value)
        self.temperature = temperature_value
    
    def update(self):
        self.density.update(self)
        self.temperature.update(self)

Is that possible somehow or is there another way to achieve a similar behavior? I have seen this question, which might help, but the answers seem quite inelegant - is there a good object-oriented approach for my case?

John Smith
  • 1,059
  • 1
  • 13
  • 35
  • If you basically just want to update all instance variable that are of type Quantity or any derived class, make the issubclass() check on all items in self.__dict__ and call update on it when issubclass returns true – Pablo Henkowski Jul 22 '20 at 18:00
  • @Pablo: John tagged his question [oop], using ``issubclass()`` and accessing ``__dict__`` sounds for me as the contrary. See e.g. these principles: https://en.wikipedia.org/wiki/SOLID. – thoku Jul 24 '20 at 18:33
  • @John: is there a special reason, for density and temperature being static members? – thoku Jul 24 '20 at 18:33
  • I'm not that familiar with OOP, not sure what a static member even is. Any constructive criticism is appreciated. – John Smith Jul 24 '20 at 18:47
  • See e.g. https://en.wikipedia.org/wiki/Class_variable. The point is, whether the variable of a class exists only once ("static") for all objects (runtime class instances), or each object has its own, allowing different values ("normal" case). In Python the access is ``MyClass.a_value`` versus ``self.a_value``. – thoku Jul 25 '20 at 06:44

2 Answers2

2

Is that possible somehow or is there another way to achieve a similar behavior?

There is a way to achieve a similar behavior.

Step 1: Set a flag on instance/MySimulation.

Step 2: Check the flag and return self in Quantity.__get__ if the flag is set.

Naive implementation(s)

4 lines change.

class Quantity(object):

    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        if hasattr(instance, '_update_context'):  # 1
            return self                           # 2
        return self.value

    def __set__(self, instance, value):
        self.value = value

    def update(self, parent):
        self.value += parent.increment  # Example update using value from parent


class MySimulation(object):
    "this default simulation has only density"
    density = Quantity()
    increment = 1

    def __init__(self, value):
        self.density = value

    def update(self):
        setattr(self, '_update_context', None)  # 3
        self.density.update(self)
        delattr(self, '_update_context')        # 4

Note that this is quite intrusive to MySimulation and its subclasses.
One way to mitigate this is to define an _update method for subclasses to override:

def update(self):
    setattr(self, '_update_context', None)  # 3
    self._update()
    delattr(self, '_update_context')        # 4

def _update(self):
    self.density.update(self)

More robust implementation

Using a metaclass, we can do with 3 lines change to the original code.

class UpdateHostMeta(type):
    UPDATE_CONTEXT_KEY = '_update_context'

    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        __class__.patch_update(cls)

    @staticmethod
    def patch_update(update_host_class):
        _update = update_host_class.update

        def update(self, *args, **kwargs):
            try:
                setattr(self, __class__.UPDATE_CONTEXT_KEY, None)
                _update(self, *args, **kwargs)
            finally:
                delattr(self, __class__.UPDATE_CONTEXT_KEY)

        update_host_class.update = update

    @staticmethod
    def is_in_update_context(update_host):
        return hasattr(update_host, __class__.UPDATE_CONTEXT_KEY)
class Quantity(object):

    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        if UpdateHostMeta.is_in_update_context(instance):  # 1
            return self                                    # 2
        return self.value

    def __set__(self, instance, value):
        self.value = value

    def update(self, parent):
        self.value += parent.increment  # Example update using value from parent


class MySimulation(object, metaclass=UpdateHostMeta):  # 3
    "this default simulation has only density"
    density = Quantity()
    increment = 1

    def __init__(self, value):
        self.density = value

    def update(self):
        self.density.update(self)
aaron
  • 39,695
  • 6
  • 46
  • 102
  • thanks for that answer, I guess that should work, but is a little more cumbersome than I hoped. If that's the only way, I think I would prefer to just use it as it is, but store the Quantities in underscored variables. If no better answer comes up I'll accept this one. – John Smith Jul 23 '20 at 13:42
  • I think what worked better for me was to inherit from a numpy ndarray and additionally give it my desired properties. – John Smith Jul 27 '20 at 13:33
  • I really like this answer. I've been trying to solve the same problem for a similar application all day. Really nice solution. – Tal Mar 03 '21 at 04:38
0

Given the different use cases descriptors allow (possible invokation bindings https://docs.python.org/3/reference/datamodel.html?highlight=descriptor%20protocol#invoking-descriptors), so harder to understand and to maintain, I'd recommend to use the property approach, if the descriptor protocol is not really needed.

You might also consider the dataclasses module, if the focus is more on keeping values than providing functionality.

I hope the following interprets your intention more or less correctly.

import numpy as np

LEN = 5

AS_PROPERTY = True  # TODO remove this line and unwanted ``Quantity`` implementation
if AS_PROPERTY:
    class Quantity:
        def __init__(self, value=None):
            self._val = value

        def getx(self):
            return self._val

        def setx(self, value):
            self._val = value

        def __repr__(self):
            return f"{self._val}"

        value = property(getx, setx)
else:
    class Quantity:  # descriptor, questionable here
        def __init__(self, value=None):
            self._val = value

        def __get__(self, instance, owner):
            return self._val

        def __set__(self, instance, value):
            self._val = value

        def __repr__(self):
            return f"{self._val}"


class Density(Quantity):
    def update(self, owner):
        idx = owner.time % len(self._val)  # simulation time determines index for change
        self._val[idx] += 0.01


class Temperature(Quantity):
    def update(self, owner):
        idx = owner.time % len(self._val)
        self._val[idx] += 1.0


class MySimulation:  # of density
    time_increment = 1

    def __init__(self, value):
        self.time = 0
        self.density = Density(value)

    def __repr__(self):
        return f"{self.density}"

    def time_step(self):
        self.time += MySimulation.time_increment

    def update(self):
        self.density.update(self)


class MySimulation2(MySimulation):  # of density and temperature
    def __init__(self, density_value, temperature_value):
        super().__init__(density_value)
        self.temperature = Temperature(temperature_value)

    def update(self):
        super().update()
        self.temperature.update(self)


if __name__ == '__main__':
    sim = MySimulation(np.arange(5.))
    sim.update()  # => [0.01, 1., 2., 3., 4.]
    print(f"sim: {sim}")

    sim2 = MySimulation2(np.linspace(.1, .5, LEN), np.linspace(10., 50., LEN))
    print(f"sim2:")
    for _ in range(2 * LEN + 1):
        print(f"{sim2.time:2}| D={sim2}, T={sim2.temperature}")
        sim2.update()
        sim2.time_step()
thoku
  • 1,120
  • 9
  • 27
  • this is not quite what I meant. This does write out the result if I call `sim.density`, but I cannot do `sim.density + 5`, I have to do `sim.density.value + 5` which is a bit cumbersome for lots of calculations. – John Smith Jul 27 '20 at 10:06
  • ``sim.density`` is an ``np.array``, you want each array element being increased by ``5``? – thoku Jul 27 '20 at 12:14
  • no I mean I want the getter to return that value, such that I can do calculations with it and not have to write .value after each quantity. – John Smith Jul 27 '20 at 13:32