27
loading...
This website collects cookies to deliver better user experience
ConstrainedValue
class. This class is a Descriptor
and is assigned to classes. When ConstrainedValue
is used in a class, that class will store ConstraintSet
objects instead of raw values. This means that we can use the constrain_with
method of ConstraintSet
to indicate that the value of that ConstraintSet
is in some way constrained. We built a trivial constraint FixedValueConstraint
which indicates that the value ofa ConstraintSet
is constrained to, unsurprisingly, a fixed value. We also built aLinkedValueConstraint
which indicates that a ConstraintSet
is constrained to the value of another ConstraintSet
. We implemented a throwaway resolve
method on ConstraintSet
so that we can test how our model hangs together. Finally we spent some time ensuring that the experience was intuitive for people using the model, and that it was possible to interrogate the model to see how things are stitched together. Whilst we were considering the concepts above we implemented a smallPoint
object and used that, with our throwaway revolve
method, to illustrate that our modelling code is effective. The full code is available in this gist, and this is an example of it in use:#...
class Point:
x = ConstrainedValue()
y = ConstrainedValue()
def __init__(self, name="", x=None, y=None):
self._name = name
if x is not None:
self.x = x
if y is not None :
self.y = y
@property
def name(self):
return self._name
p = Point('p', 1,2)
q = Point('q')
print(f"p.x is {p.x}")
print(f"p.x resolves to {p.x.resolve()}")
q.x = p.x
print(f"q.x is {repr(q.x)}")
print(f"q.x resolves to {q.x.resolve()}")
p.x = 2
print(f"Now p.x has changed, q.x resolves to {q.x.resolve()}")
p.x is p.x
p.x resolves to 1
q.x is ConstraintSet(
LinkedValueConstraint<p.x>
)
q.x resolves to 1
Now p.x has changed, q.x resolves to 2
x
is only known if the value of y
is known and visa versa; yet neither can take arbitrary values.Before we consider how we might do that, let's build a simple line object. For the moment we won't worry about using ConstrainedValue
in this line, as we wish to constrain the point to the line. We can revisit the Line
object later on when we've established some patterns.class Line:
def __init__(self, x1, y1, x2, y2):
self.x1 = x1
self.y1 = y1
self.x2 = x2
self.y2 = y2
def __repr__(self):
return f"Line<({self.x1},{self.y1}),({self.x2},{self.y2})>"
l = Line(0,1,2,3)
print(l)
Line<(0,1),(2,3)>
x1
, x2
etc. It should serve our needs for the moment. Now how might we indicate that our Point
is constrained to this line? The answer is surprisingly simple. The Point
needs to be able to accept a constraint, and the easiest way to do that is to have it inherit from ConstraintSet
:class Point(ConstraintSet):
x = ConstrainedValue()
y = ConstrainedValue()
def __init__(self, name="", x=None, y=None):
super().__init__(self)
self._name = name
if x is not None:
self.x = x
if y is not None :
self.y = y
@property
def name(self):
return self._name
ConstraintSet
as a superclass, and the first line of __init__
which adds the call to the superclass initializer. As we've now inherited from ConstraintSet
, our Point
class has a constrain_with
method which can use to supply a Constraint
which indicates that our point should be constrained to a Line
. The fancy pants maths people say that if a point is on a line, then it "coincident", so let's use their fancy pants language and define a CoincidentConstraint
:class CoincidentConstraint(Constraint):
def __init__(self, line):
self._line = line
@property
def line(self):
return self._line
def __repr__(self):
return f"{self.__class__.__name__}<{self.line}>"
l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p))
ConstraintSet(
CoincidentConstraint<Line<(1,2),(3,4)>>
)
ConstraintSet
, and whilst that is true to a certain extent, it should really tell us that it is aPoint
. The issue is because when we originally wrote the __repr__
method in ConstraintSet
we hardcoded the class name (tsk). Let's address that:class ConstraintSet:
#...
def __repr__(self):
retval = f"{self.__class__.__name__}("
if len(self._constraints) == 0:
retval += ")"
return retval
for constraint in self._constraints:
retval += f"\n {constraint}"
retval += "\n)"
return retval
#...
l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p))
Point(
CoincidentConstraint<Line<(1,2),(3,4)>>
)
Point
is a Point
again, and its correctly reporting its constraints.Point
?l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
ConstraintSet()
x
coordinate, it is not true that the x
coordinate is not constrained. It would be much better if the x
coordinate could tell us that it was being constrained by the CoincidentConstraint
at the Point
level.We could just have the constrain_with
method apply the CoincidentConstraint
to the x
and y
coordinates, and this is certainly a valid approach.x
and y
co-ordinates. Constraining a line to being horizontal is only constraining the y
co-ordinates of those two point (constraining them to being equal) and there is no way that our constrain_with
method can know this. If we were to just apply the HorizontalConstraint
to all the properties then we'd end up with the x
co-ordinates showing the that they were constrained, which is misleading.Constraint
implementations which will understand which properties need to be constrained. So we need a mechanism for the constrain_with
method to give the constraint the means to, in turn, constrain the properties of the object it is constraining. To achieve this we'll use a design pattern called a "callback". We'll define a constrain_callback
method on the Constraint
which accepts a ConstraintSet
object.Then we will update our constrain_with
method to call that constrain_callback
method. Theconstrain_callback
method can then be overridden by the various implementations of Constraint
to apply the necessary downstream constraints.Constraint
definition to include a constraint_callback
method.We face a design decision at this point. We can either declare this method to be an@abstractmethod
which will force implementations of Constraint
to implement it, or we can provide a default implementation, which will do nothing, meaning that our implementations can just pretend the constraint_callback
doesn't exist if they don't wish to constrain properties. It's not particularly clear which direction we should go here. If we provide a default implementation then we risk our users forgetting that it needs to be implemented, and therefore some nasty bugs. If we don't then implementations will have to explicitly state that they want the constraint_callback
to do nothing. This is simply a question of style, neither option is particularly better than the other. For pythonic questions of style we should always refer to PEP20 - the zen of python and yet again, in this case, it does not let us down. Rule 2 is "Explicit is better than implicit." so rather than providing a default implementation which implicitly does nothing, we will require our implementations to provide an explicit implementation of the method which does nothing (if that's what they require). In practice this means that we will decorate our method with @abstractmethod
:from abc import ABC, abstractmethod
class Constraint(ABC):
"""Used to restrict that value of a `ConstrainedValue`."""
@abstractmethod
def constraint_callback(self, instance):
raise NotImplementedError("`constraint_callback` must be implemented explicitly.")
FixedValueConstraint
and LinkedValueConstraint
it can simply do nothing. CoincidentConstraint
is a touch more complicated so we'll look at that a little later.class FixedValueConstraint(Constraint):
#...
def constraint_callback(self, instance):
pass
class LinkedValueConstraint(Constraint):
#...
def constraint_callback(self, instance):
pass
constrain_with
method to call the callback:class ConstraintSet:
#...
def constrain_with(self, constraint):
constraint.constraint_callback(self)
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
#...
CoincidentConstraint
to the x
and y
. I've thought about this, and I can't see a reason why it technically wouldn't work, however it does create an ambiguity. It would not be clear if the CoincidentConstraint
had been applied directly to the x
or if it had inherited it from its parent shape. So to avoid this we're going to create an InfluencedConstraint
. This new constraint will indicate that the parameter is constrained by something higher up the food chain.class InfluencedConstraint(Constraint):
def __init__(self, constraint):
self._constraint = constraint
def constraint_callback(self, instance):
pass
@property
def constraint(self):
return self._constraint
def __repr__(self):
return f"{self.__class__.__name__}<{self.constraint}>"
callback_constraint
method on the CoincidentConstraint
to have it indicate that it is influencing the two co-ordinates.class CoincidentConstraint(Constraint):
#...
def constraint_callback(self, point):
point.x.constrain_with(InfluencedConstraint(self))
point.y.constrain_with(InfluencedConstraint(self))
l = Line(1,2,3,4)
p = Point('p')
p.constrain_with(CoincidentConstraint(l))
print(f"p is {repr(p)}")
print(f"p.x is {repr(p.x)}")
p is Point(
CoincidentConstraint<Line<(1,2),(3,4)>>
)
p.x is ConstraintSet(
InfluencedConstraint<CoincidentConstraint<Line<(1,2),(3,4)>>>
)
Point
object is expressing that it is constrained by theCoincidentConstraint
and if we look at the x
coordinate we can see that it is influenced by theCoincidentConstraint
and even see exactly which line the CoincidentConstraint
is constraining to. This might get a bit verbose in complex diagrams, but right now its perfect for our needs, so let's leave it like that and worry about verbosity if and when it becomes an issue.p.x
with aCoincidentConstraint
. As CoincidentConstraint
is only designed to apply to Point
objects, it's likely to do something a little strange when applied to a ConstraintSet
:l = Line(1,2,3,4)
p = Point('p')
p.x.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
AttributeError: 'ConstraintSet' object has no attribute 'x'
constraint_callback
check that the constraint is being applied to a sensible object. In this case it only really makes sense forCoincidentConstraint
to be applied to a Point
, so let's enforce that. And let's create a customRuntimeError
object with a nice error message to tell our users what is going on.class InvalidConstraintException(RuntimeError):
"""Indicates that a constraint has been applied to an object which doesn't make sense."""
class CoincidentConstraint(Constraint):
#...
def constraint_callback(self, point):
if not isinstance(point, Point):
raise InvalidConstraintException(f"{self.__class__.__name__} can only"
f" be applied to `Point`, it cannot be applied to `{point.__class__.__name__}`")
point.x.constrain_with(InfluencedConstraint(self))
point.y.constrain_with(InfluencedConstraint(self))
l = Line(1,2,3,4)
p = Point('p')
p.x.constrain_with(CoincidentConstraint(l))
print(repr(p.x))
InvalidConstraintException: CoincidentConstraint can only be applied to `Point`, it cannot be applied to `ConstraintSet`.
Constraint
objects, and we have a clean way of applying these to our geometric objects. We have clear output when we ask the various objects to repr
themselves, and we have clear error messages when things don't quite go to plan. There are just a couple of corners we need to clear up before we can call ourselves done. Look at the following example:p = Point('p')
q = Point('q')
q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
p.x is ConstraintSet()
q.x is ConstraintSet(
LinkedValueConstraint<p.x>
)
q
was given a value first? Equality is a reciprocal condition, and when we assert that q.x = p.x
we are equally saying that p.x = q.x
.We could force this on our users. We could decide that for the purposes of our geometric solver,such operations are not reciprocal, and that users must specify both sides of the relationship:p = Point('p')
q = Point('q')
q.x = p.x
p.x = q.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
p.x is ConstraintSet(
LinkedValueConstraint<q.x>
)
q.x is ConstraintSet(
LinkedValueConstraint<p.x>
)
l
is parallel to m
then m
is parallel to l
. Even with ourCoincidentConstraint
if our point is on the line, then our line must go through the point. Let's update our LinkedValueConstraint
to apply the reciprocal constraint.class LinkedValueConstraint(Constraint):
#...
def constraint_callback(self, instance):
if not isinstance(instance, ConstraintSet):
raise InvalidConstraintException(f"{self.__class__.__name__} can only"
f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")
self.constraint_set.constrain_with(LinkedValueConstraint(instance))
pass
p = Point('p')
q = Point('q')
q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
RecursionError: maximum recursion depth exceeded while calling a Python object
LinkedValueConstraint
applies a reciprocal constraint by enforcing a LinkedValueConstraint
on the original ConstraintSet
which in turn tries to apply a reciprocal constraint etc. And the whole world explodes in a glorious stack overflow (first one of the project though, I'm proud!). We could fix this by adding some kind of flag to constrain_with
and constraint_callback
to indicate that this is a reciprocal call and that they should not apply the reciprocal constraint as its already done. This is crying out to be forgotten and to lead to some nasty stack overflow type bugs. I think a neater solution is to haveconstrain_with
check to see if an identical constraint has already been applied and just exit cleanly if it has. To do this we need to establish what it means for two LinkedValueConstraints
to be equal:class LinkedValueConstraint(Constraint):
#...
def __eq__(self, other):
return isinstance(other, self.__class__) and self.constraint_set == other.constraint_set
LinkedValueConstraint
is only equal to another object, if and only if that other object is also a LinkedValueConstraint
and they are both linked to the same ConstraintSet
.p = Point()
l = LinkedValueConstraint(p.x)
m = LinkedValueConstraint(p.x)
n = LinkedValueConstraint(q.x)
print(f"l == m: {l == m}")
print(f"l == n: {l == n}")
l == m: True
l == n: False
constrain_with
method:class ConstraintSet:
#...
def constrain_with(self, constraint):
if constraint in self._constraints:
return
constraint.constraint_callback(self)
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
#...
p = Point('p')
q = Point('q')
q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
RecursionError: maximum recursion depth exceeded
constraint_callback
to apply the reciprocal constraint, but that is being called before the constraint is added toself._constraints
in the Constraint
class. We need it to be that way around because theconstraint_callback
is also validating that the object is of a suitable type. Aha! This is our issue. Every time we have to use the work "also" to describe what an object or method is doing, then we likely have ourselves a separation of concerns issue. This was the mistake which I gave you the spoiler alert for earlier! Let's split our constraint_callback
into three: a validate_object
method, an apply_reciprocal_constraint
method, and finally thecascade_constraints
.from abc import ABC, abstractmethod
class Constraint(ABC):
"""Used to restrict that value of a ```
ConstrainedValue
```."""
@abstractmethod
def validate_object(self, instance):
"""Validates that `instance` is suitable. Raises `InvalidConstraintException` if not"""
raise NotImplementedError("`validate_object` must be implemented explicitly.")
@abstractmethod
def apply_reciprocal_constraint(self, instance):
"""Applies a matching constraint to the provided instance."""
raise NotImplementedError("`apply_reciprocal_callback` must be implemented explicitly.")
@abstractmethod
def cascade_constraints(self, instance):
"""Applies appropriate constraints to the properties of `instance`."""
raise NotImplementedError("`cascade_constraints` must be implemented explicitly.")
LinkedValueConstraint
:class LinkedValueConstraint(Constraint):
#...
def validate_object(self, instance):
if not isinstance(instance, ConstraintSet):
raise InvalidConstraintException(f"{self.__class__.__name__} can only"
f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")
def apply_reciprocal_constraint(self, instance):
self.constraint_set.constrain_with(LinkedValueConstraint(instance))
def cascade_constraints(self, instance):
pass
#...
ConstraintSet
:class ConstraintSet:
#...
def constrain_with(self, constraint):
constraint.validate_object(self)
if constraint in self._constraints:
return
"""Add a constraint to this objects list of constraints."""
self._constraints.append(constraint)
constraint.cascade_constraints(self)
constraint.apply_reciprocal_constraint(self)
#...
constrain_with
method much more readable. Let's see if its finally resolved our stack overflow.p = Point('p')
q = Point('q')
q.x = p.x
print(f"p.x is {repr(p.x)}")
print(f"q.x is {repr(q.x)}")
p.x is ConstraintSet(
LinkedValueConstraint<q.x>
)
q.x is ConstraintSet(
LinkedValueConstraint<p.x>
)
FixedValueConstraint
is trivial, we shall just implement each as a no-op:class FixedValueConstraint(Constraint):
#...
def validate_object(self, instance):
pass
def apply_reciprocal_constraint(self, instance):
pass
def cascade_constraints(self, instance):
pass
InfluencedConstraint
at first feels like it might be a little more complex. Validation is straightforward, it can only apply to another ConstraintSet
object, or child object. But what about the reciprocal constraint? In fact, we don't need to do anything here, as the reciprocal object is the "higher up one". If, for example, InfluencedConstraint
is being applied to p.x
, that's becauseCoincidentConstraint
has been applied to p
so we already know that the reciprocal constraint isin place, as such we don't need to do anything. Similarly we can make no assumptions as to what constraints will need to be cascaded and we'll leave that to the "higher up" object should that be necessary:class InfluencedConstraint(Constraint):
#...
def validate_object(self, instance):
if not isinstance(instance, ConstraintSet):
raise InvalidConstraintException(f"{self.__class__.__name__} can only"
f" be applied to `ConstraintSet`, it cannot be applied to `{point.__class__.__name__}`")
def apply_reciprocal_constraint(self, instance):
pass
def cascade_constraints(self, instance):
pass
LinkedValueConstraint
so that only leaves CoincidentConstraint
. In order todo that we'll need to upgrade our Line
object to be able to take constraints, and as this article is already about twice as long as I intended, let's leave that for next time! In the next article we'll look at upgrading our Line
to be able to accept Constraint
objects, and in so doing we'll walk a path towards some pretty powerful generics.