Repository: matthewryanscott/CarlAdam

Issue:
Expose effects of a transition occurrence, for direct inspection and enhanced simulation
### Current

You have a Petri net instance, and you can get a `marking_after_transition` based on a `marking` and a `transition`.

However, the mechanism of constructing the new marking is internal to this function and can only be tested as a black box. Its internal algorithm also cannot be exploited for any other purpose than to get a new marking.

This means that you have to infer what the function did by comparing the input and output markings.

### Desired

You have a Petri net instance, and you can get the `transition_effects` for a given `marking` and `transition`. Those effects are a list of operations that can be applied 

You can call `apply_effects(marking, effects)` and it will return a new marking with the effects applied.

Therefore, the implementation of `marking_after_transition` would be reduced to:

```python
def marking_after_transition(self, marking, transition):
    effects = self.transition_effects(marking, transition)
    return apply_effects(marking, effects)
```

You can also directly inspect the effects in tests, and they could also be used as a basis for showing additional context & animations in a simulator.

Discussion:

Patch:
diff --git a/carladam/petrinet/arc.py b/carladam/petrinet/arc.py
index 3b2d07f..5db00e1 100644
--- a/carladam/petrinet/arc.py
+++ b/carladam/petrinet/arc.py
@@ -9,7 +9,7 @@ from __future__ import annotations
 
 # Python imports
 from functools import lru_cache
-from typing import TYPE_CHECKING, Callable, Iterator, cast
+from typing import Callable, Iterator, TYPE_CHECKING, overload
 
 # Pip imports
 from attr import Factory, define, field
@@ -23,7 +23,6 @@ from carladam.petrinet.place import Place
 from carladam.petrinet.token import Token, TokenSet
 from carladam.petrinet.transition import Transition
 
-
 if TYPE_CHECKING:  # pragma: nocover
     # Internal imports
     from carladam.petrinet.types import Arc
@@ -77,7 +76,7 @@ class ArcPT:
 
     def __lshift__(self, other: Place | Annotate | TransformEach) -> ArcPT:
         if isinstance(other, (Annotate, TransformEach)):
-            return cast(ArcPT, other.apply_to_arc(self))
+            return other.apply_to_arc(self)
         if self.dest is None:
             raise errors.PetriNetArcIncomplete("Cannot << to an arc not having a dest")
         return CompletedArcPT(
@@ -90,7 +89,7 @@ class ArcPT:
 
     def __rshift__(self, other: Transition | Annotate | TransformEach) -> ArcPT:
         if isinstance(other, (Annotate, TransformEach)):
-            return cast(ArcPT, other.apply_to_arc(self))
+            return other.apply_to_arc(self)
         if self.src is None:
             raise errors.PetriNetArcIncomplete("Cannot >> from an arc not having a src")
         return CompletedArcPT(
@@ -144,7 +143,7 @@ class ArcTP:
 
     def __lshift__(self, other: Transition | Annotate | TransformEach) -> ArcTP:
         if isinstance(other, (Annotate, TransformEach)):
-            return cast(ArcTP, other.apply_to_arc(self))
+            return other.apply_to_arc(self)
         if self.dest is None:
             raise errors.PetriNetArcIncomplete("Cannot << to an arc not having a dest")
         return CompletedArcTP(
@@ -157,7 +156,7 @@ class ArcTP:
 
     def __rshift__(self, other: Place | Annotate | TransformEach) -> ArcTP:
         if isinstance(other, (Annotate, TransformEach)):
-            return cast(ArcTP, other.apply_to_arc(self))
+            return other.apply_to_arc(self)
         if self.src is None:
             raise errors.PetriNetArcIncomplete("Cannot >> from an arc not having a src")
         return CompletedArcTP(
@@ -193,6 +192,12 @@ class Annotate:
     text: str
     "Text used to describe an `Arc`."
 
+    @overload
+    def apply_to_arc(self, arc: ArcPT) -> ArcPT: ...
+
+    @overload
+    def apply_to_arc(self, arc: ArcTP) -> ArcTP: ...
+
     def apply_to_arc(self, arc: Arc) -> Arc:
         return type(arc)(
             src=arc.src,  # type: ignore
@@ -210,6 +215,12 @@ class TransformEach:
     fn: Callable
     "Function taking a `Token` and returning a `Token`."
 
+    @overload
+    def apply_to_arc(self, arc: ArcPT) -> ArcPT: ...
+
+    @overload
+    def apply_to_arc(self, arc: ArcTP) -> ArcTP: ...
+
     def apply_to_arc(self, arc: Arc) -> Arc:
         def transformed_tokens(tokens: TokenSet) -> Iterator[Token]:
             for token in tokens:
diff --git a/carladam/petrinet/effects.py b/carladam/petrinet/effects.py
new file mode 100644
index 0000000..75d3bad
--- /dev/null
+++ b/carladam/petrinet/effects.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+from typing import Sequence
+
+import attrs
+from pyrsistent import pset
+
+from carladam.petrinet.arc import CompletedArcPT, CompletedArcTP
+from carladam.petrinet.marking import PMarking
+from carladam.petrinet.token import Token
+
+
+@attrs.define
+class Consume:
+    """
+    A token was consumed from a place by an arc.
+
+    The token will always represent a token that was present in a place prior to a transition occurrence.
+    """
+
+    arc: CompletedArcPT
+    token: Token
+
+    def apply_to_marking(self, marking: PMarking) -> PMarking:
+        new_place_tokens = marking.get(self.arc.src, pset()).remove(self.token)
+        if new_place_tokens:
+            return marking.set(self.arc.src, pset(new_place_tokens))
+        return marking.remove(self.arc.src)
+
+
+@attrs.define
+class Input:
+    """
+    A token was passed to a transition as an input.
+
+    The token may be one that was consumed from a place, or one that was generated by an arc's `transform` function.
+    """
+
+    arc: CompletedArcPT
+    token: Token
+
+    def apply_to_marking(self, marking: PMarking) -> PMarking:
+        return marking
+
+
+@attrs.define
+class Output:
+    """
+    A token was returned by a transition as an output.
+
+    The token will always represent a token that was generated by the transition's `fn` function.
+    """
+
+    arc: CompletedArcTP
+    token: Token
+
+    def apply_to_marking(self, marking: PMarking) -> PMarking:
+        return marking
+
+
+@attrs.define
+class Produce:
+    """
+    A token was produced to a place by an arc.
+
+    The token may be one that was generated by a transition's `fn` function,
+    or it may be one that was generated by an arc's `transform` function.
+    """
+
+    arc: CompletedArcTP
+    token: Token
+
+    def apply_to_marking(self, marking: PMarking) -> PMarking:
+        new_place_tokens = marking.get(self.arc.dest, pset()).add(self.token)
+        return marking.set(self.arc.dest, pset(new_place_tokens))
+
+
+Effect = Consume | Input | Output | Produce
+
+
+def apply_effects_to_marking(marking: PMarking, effects: Sequence[Effect]) -> PMarking:
+    for effect in effects:
+        marking = effect.apply_to_marking(marking)
+    return marking
diff --git a/carladam/petrinet/errors.py b/carladam/petrinet/errors.py
index 9ffc184..023ef0e 100644
--- a/carladam/petrinet/errors.py
+++ b/carladam/petrinet/errors.py
@@ -6,9 +6,5 @@ class PetriNetArcIncomplete(PetriNetException):
     """The arc cannot be added because its endpoints are not set."""
 
 
-class PetriNetTransitionNotEnabled(PetriNetException):
-    """The transition cannot occur because it is not enabled."""
-
-
 class PetriNetTransitionFunctionOutputHasOverlappingColorsets(PetriNetException):
     """The transition cannot occur due to overlapping colorsets of the output tokensets."""
diff --git a/carladam/petrinet/occurrence.py b/carladam/petrinet/occurrence.py
new file mode 100644
index 0000000..c58f102
--- /dev/null
+++ b/carladam/petrinet/occurrence.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from collections import Counter
+from typing import Mapping, Sequence, TYPE_CHECKING
+
+import attrs
+from pyrsistent import plist, pmap, pset
+from pyrsistent.typing import PList
+
+from carladam.petrinet.arc import CompletedArcPT, CompletedArcTP
+from carladam.petrinet.color import ColorSet
+from carladam.petrinet.effects import Consume, Effect, Input, Output, Produce
+from carladam.petrinet.errors import PetriNetTransitionFunctionOutputHasOverlappingColorsets
+from carladam.petrinet.marking import PMarking
+from carladam.petrinet.token import TokenSet
+from carladam.petrinet.transition import Transition
+
+if TYPE_CHECKING:
+    from carladam.petrinet.petrinet import PetriNet  # pragma: nocover
+
+
+@attrs.define
+class Occurrence:
+    net: PetriNet
+    marking: PMarking
+    transition: Transition
+
+    def is_enabled(self) -> bool:
+        try:
+            self.check_enabled()
+        except NotEnabled:
+            return False
+        return True
+
+    def check_enabled(self):
+        if not self.net.node_inputs.get(self.transition) and not self.net.node_outputs.get(self.transition):
+            raise NoArcs()
+        # Are there enough tokens?
+        for arc in self.input_arcs():
+            input_colorset = self.input_colorset_for_arc(arc)
+            if frozenset(arc.weight.keys()) - frozenset(input_colorset.keys()):
+                # Arc weight specifies more colors than Place has.
+                raise ArcWeightColorsNotSatisfied()
+            if not self.input_colorset_satisfies_weight(arc.weight, input_colorset):
+                # Arc weight specifies more of some color token than place contains.
+                raise ArcWeightQuantityNotSatisfied()
+        # Is the guard satisfied?
+        try:
+            guard_result = self.transition.guard(self.transition_inputs())
+        except Exception as e:
+            raise TransitionGuardRaisesException() from e
+        if not guard_result:
+            raise TransitionGuardReturnsFalse()
+
+    def input_colorset_satisfies_weight(self, weight, colors):
+        return all(color in weight and weight[color] <= count for color, count in colors.items())
+
+    def input_colorset_for_arc(self, arc) -> ColorSet:
+        return Counter(token.color for token in self.marking.get(arc.src, set()))
+
+    def input_arcs(self) -> Sequence[CompletedArcPT]:
+        return self.net.node_inputs.get(self.transition, ())
+
+    def transition_inputs(self):
+        inputs = set()
+        for arc in self.input_arcs():
+            place = arc.src
+            new_place_tokens = set(self.marking[place])
+            colors_left = dict(arc.weight)
+            for token in self.marking[place]:
+                if colors_left.get(token.color, 0):
+                    inputs.add(token)
+                    new_place_tokens.remove(token)
+                    colors_left[token.color] -= 1
+        return pset(inputs)
+
+    def effects(self) -> PList[Effect]:
+        self.check_enabled()
+        effects = []
+        # Consume inputs.
+        inputs = set()
+        for arc in self.input_arcs():
+            place = arc.src
+            colors_left = dict(arc.weight)
+            inputs_to_add = set()
+            for token in self.marking[place]:
+                if colors_left.get(token.color, 0):
+                    inputs_to_add.add(token)
+                    effects.append(Consume(arc=arc, token=token))
+                    colors_left[token.color] -= 1
+            if callable(arc.transform):
+                inputs_to_add = arc.transform(inputs_to_add)
+            effects.extend(Input(arc=arc, token=token) for token in inputs_to_add)
+            inputs.update(inputs_to_add)
+        # Calculate outputs.
+        output_tokensets = pset(pset(tokenset) for tokenset in self.transition.fn(pset(inputs)))
+        # Ensure each output's colorset is unique amongst all outputs.
+        # This is to avoid ambiguity when placing tokens into destinations.
+        output_tokensets_by_colorset: Mapping[ColorSet, TokenSet] = {
+            pmap(Counter(token.color for token in tokenset)): tokenset for tokenset in output_tokensets
+        }
+        if len(output_tokensets_by_colorset) < len(output_tokensets):
+            raise PetriNetTransitionFunctionOutputHasOverlappingColorsets()
+        # Produce outputs.
+        for arc in self.output_arcs():
+            outputs_for_place = output_tokensets_by_colorset[arc.weight]
+            effects.extend(Output(arc=arc, token=token) for token in outputs_for_place)
+            if callable(arc.transform):
+                outputs_for_place = arc.transform(outputs_for_place)
+            effects.extend(Produce(arc=arc, token=token) for token in outputs_for_place)
+        return plist(effects)
+
+    def output_arcs(self) -> Sequence[CompletedArcTP]:
+        return self.net.node_outputs.get(self.transition, ())
+
+
+class NotEnabled(Exception):
+    pass
+
+
+class NoArcs(NotEnabled):
+    pass
+
+
+class ArcWeightColorsNotSatisfied(NotEnabled):
+    pass
+
+
+class ArcWeightQuantityNotSatisfied(NotEnabled):
+    pass
+
+
+class TransitionGuardReturnsFalse(NotEnabled):
+    pass
+
+
+class TransitionGuardRaisesException(Exception):
+    pass
diff --git a/carladam/petrinet/petrinet.py b/carladam/petrinet/petrinet.py
index ab1a51c..6387546 100644
--- a/carladam/petrinet/petrinet.py
+++ b/carladam/petrinet/petrinet.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 # Python imports
-from collections import Counter
 from collections.abc import Generator, Iterable
 from functools import lru_cache
 from itertools import chain
@@ -13,14 +12,13 @@ from pyrsistent import PList, PMap, PSet, pmap, pset
 
 # Internal imports
 from carladam.petrinet import errors
-from carladam.petrinet.arc import CompletedArcPT, CompletedArcTP
-from carladam.petrinet.color import Abstract, Color, ColorSet
+from carladam.petrinet.color import Abstract, Color
+from carladam.petrinet.effects import apply_effects_to_marking
 from carladam.petrinet.marking import Marking, PMarking, pmarking
+from carladam.petrinet.occurrence import Occurrence
 from carladam.petrinet.place import Place
-from carladam.petrinet.token import TokenSet
 from carladam.petrinet.transition import Transition
 from carladam.petrinet.types import (
-    Arc,
     ArcTypes,
     CompletedArc,
     CompletedArcTypes,
@@ -125,8 +123,7 @@ class PetriNet(metaclass=PetriNetMeta):
         for arc in self.node_inputs.get(node, ()):
             net = net.update(arc.src, arc)
         for arc in self.node_outputs.get(node, ()):
-            dest = cast(Arc, arc.dest)  # Arc | None -> Arc -- update() guards against None.
-            net = net.update(dest, arc)
+            net = net.update(arc.dest, arc)
         return net
 
     def update(self, *others: PetriNetMemberOrSet) -> PetriNet:
@@ -181,49 +178,8 @@ class PetriNet(metaclass=PetriNetMeta):
 
     @lru_cache
     def _marking_after_transition(self, marking: PMarking, transition: Transition) -> PMarking:
-        new_marking = marking
-        if not self.transition_is_enabled(marking, transition):
-            raise errors.PetriNetTransitionNotEnabled()
-        # Consume inputs.
-        inputs = set()
-        place: Place
-        for arc in self.node_inputs.get(transition, ()):
-            # [TODO] remove casts after typing cleanup
-            # We cast to Place here since it's always set in this case.
-            place = cast(CompletedArcPT, arc).src
-            new_place_tokens = set(new_marking.get(place, pset()))
-            colors_left = dict(arc.weight)
-            inputs_to_add = set()
-            for token in marking[place]:
-                if colors_left.get(token.color, 0):
-                    inputs_to_add.add(token)
-                    new_place_tokens.remove(token)
-                    colors_left[token.color] -= 1
-            if callable(arc.transform):
-                inputs_to_add = arc.transform(inputs_to_add)
-            inputs.update(inputs_to_add)
-            new_marking = new_marking.set(place, pset(new_place_tokens))
-        # Calculate outputs.
-        output_tokensets = pset(pset(tokenset) for tokenset in transition.fn(pset(inputs)))
-        # Ensure each output's colorset is unique amongst all outputs.
-        # This is to avoid ambiguity when placing tokens into destinations.
-        output_tokensets_by_colorset: Mapping[ColorSet, TokenSet] = {
-            pmap(Counter(token.color for token in tokenset)): tokenset for tokenset in output_tokensets
-        }
-        if len(output_tokensets_by_colorset) < len(output_tokensets):
-            raise errors.PetriNetTransitionFunctionOutputHasOverlappingColorsets()
-        # Produce outputs.
-        for arc in self.node_outputs.get(transition, ()):
-            arc = cast(CompletedArcTP, arc)  # Arc -> ArcTP -- We know the type of the arc.
-            place = cast(Place, arc.dest)  # Place | None -> Place -- update() guards against None.
-            outputs_for_place = output_tokensets_by_colorset[arc.weight]
-            if callable(arc.transform):
-                outputs_for_place = arc.transform(outputs_for_place)
-            new_marking = new_marking.set(place, new_marking.get(place, pset()).union(outputs_for_place))
-        for key, value in new_marking.items():
-            if not value:
-                new_marking = new_marking.discard(key)
-        return new_marking
+        effects = Occurrence(self, marking, transition).effects()
+        return apply_effects_to_marking(marking, effects)
 
     def transition_is_enabled(self, marking: Marking, transition: Transition) -> bool:
         """Returns True if the given `Transition` is enabled in this net given a `Marking`."""
@@ -231,31 +187,7 @@ class PetriNet(metaclass=PetriNetMeta):
 
     @lru_cache
     def _transition_is_enabled(self, marking: PMarking, transition: Transition) -> bool:
-        if not self.node_inputs.get(transition) and not self.node_outputs.get(transition):
-            return False
-        # Are there enough tokens?
-        place: Place
-        for arc in self.node_inputs.get(transition, ()):
-            place = cast(CompletedArcPT, arc).src
-            colors: ColorSet = Counter(token.color for token in marking.get(place, set()))
-            if frozenset(arc.weight) - frozenset(colors):
-                # Arc weight specifies more colors than Place has.
-                return False
-            if any(color not in arc.weight or arc.weight[color] > count for color, count in colors.items()):
-                # Arc weight specifies more of some color token than place contains.
-                return False
-        # Is the guard satisfied?
-        inputs = set()
-        for arc in self.node_inputs.get(transition, ()):
-            place = cast(CompletedArcPT, arc).src
-            new_place_tokens = set(marking[place])
-            colors_left = dict(arc.weight)
-            for token in marking[place]:
-                if colors_left.get(token.color, 0):
-                    inputs.add(token)
-                    new_place_tokens.remove(token)
-                    colors_left[token.color] -= 1
-        return transition.guard(pset(inputs))
+        return Occurrence(self, marking, transition).is_enabled()
 
     def transition_is_external(self, transition: Transition) -> bool:
         """Returns True if a given `Transition` is external in relation to this net."""
diff --git a/carladam/petrinet/place.py b/carladam/petrinet/place.py
index cb0b3ca..022cd03 100644
--- a/carladam/petrinet/place.py
+++ b/carladam/petrinet/place.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 # Python imports
-from typing import TYPE_CHECKING, AbstractSet
+from typing import AbstractSet, TYPE_CHECKING, overload
 
 # Pip imports
 from attr import define, field
@@ -12,10 +12,9 @@ from carladam.petrinet import defaults
 from carladam.petrinet.color import Color, ColorSet
 from carladam.petrinet.defaults import default_id
 
-
 if TYPE_CHECKING:  # pragma: nocover
     # Internal imports
-    from carladam.petrinet.arc import ArcPT, ArcTP
+    from carladam.petrinet.arc import ArcPT, ArcTP, CompletedArcPT
     from carladam.petrinet.transition import Transition
 
 
@@ -70,7 +69,13 @@ class Place:
 
         raise TypeError("Place cannot be connected to object.", other)
 
-    def __rshift__(self, other: Transition | Color | ColorSet | AbstractSet[Color]) -> ArcPT:
+    @overload
+    def __rshift__(self, other: Transition) -> CompletedArcPT: ...
+
+    @overload
+    def __rshift__(self, other: Color | ColorSet | AbstractSet[Color]) -> ArcPT: ...
+
+    def __rshift__(self, other: Transition | Color | ColorSet | AbstractSet[Color]) -> ArcPT | CompletedArcPT:
         """Returns an arc from this `Place` to the given `Transition`."""
         # Internal imports
         from carladam.petrinet.arc import ArcPT, CompletedArcPT
diff --git a/carladam/petrinet/transition.py b/carladam/petrinet/transition.py
index 766288e..46d7ee5 100644
--- a/carladam/petrinet/transition.py
+++ b/carladam/petrinet/transition.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 # Python imports
 from collections import Counter, defaultdict
 from collections.abc import Iterable
-from typing import TYPE_CHECKING, AbstractSet, Callable, Iterator, cast
+from typing import AbstractSet, Callable, Iterator, TYPE_CHECKING, cast, overload
 
 # Pip imports
 from attr import define, field
@@ -15,13 +15,11 @@ from carladam.petrinet.color import Abstract, Color, ColorSet, MutableColorSet
 from carladam.petrinet.defaults import default_id
 from carladam.petrinet.token import TokenSet
 
-
 if TYPE_CHECKING:  # pragma: nocover
     # Internal imports
-    from carladam.petrinet.arc import ArcPT, ArcTP
+    from carladam.petrinet.arc import ArcPT, ArcTP, CompletedArcTP
     from carladam.petrinet.place import Place
 
-
 TransitionGuard = Callable[[TokenSet], bool]
 "Type of function receiving a set of `Token`s from input `Place`s and returning `True` if guard(s) are met."
 
@@ -163,7 +161,13 @@ class Transition:
             return CompletedArcPT(src=other, dest=self)
         raise TypeError("Transition cannot be connected to object.", other)
 
-    def __rshift__(self, other: Place | Color | ColorSet | AbstractSet[Color]) -> ArcTP:
+    @overload
+    def __rshift__(self, other: Place) -> CompletedArcTP: ...
+
+    @overload
+    def __rshift__(self, other: Color | ColorSet | AbstractSet[Color]) -> ArcTP: ...
+
+    def __rshift__(self, other: Place | Color | ColorSet | AbstractSet[Color]) -> ArcTP | CompletedArcTP:
         """Returns an arc from this `Transition` to the given `Place`."""
         # Internal imports
         from carladam.petrinet.arc import ArcTP, CompletedArcTP


Test patch:
diff --git a/tests/test_effects.py b/tests/test_effects.py
new file mode 100644
index 0000000..835d385
--- /dev/null
+++ b/tests/test_effects.py
@@ -0,0 +1,132 @@
+from carladam import Abstract, Place, Transition
+from carladam.petrinet.effects import Consume, Input, Output, Produce
+from carladam.petrinet.marking import pmarking
+
+
+def test_consume_place_will_have_remaining_token():
+    # Given: a place, a transition, and an arc from the place to the transition
+    p = Place()
+    t = Transition()
+    arc = p >> t
+
+    # And: a marking with a token
+    token = Abstract()
+    token2 = Abstract()
+    marking = pmarking({p: {token, token2}})
+
+    # And: an effect indicating that a token was consumed
+    effect = Consume(arc, token2)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: the token is removed from the place
+    expected = pmarking({p: {token}})
+    assert marking_after_effect == expected
+
+
+def test_consume_place_will_be_empty():
+    # Given: a place, a transition, and an arc from the place to the transition
+    p = Place()
+    t = Transition()
+    arc = p >> t
+
+    # And: a marking with a token
+    token = Abstract()
+    marking = pmarking({p: {token}})
+
+    # And: an effect indicating that the token was consumed
+    effect = Consume(arc, token)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: the token is removed from the place
+    expected = pmarking({})
+    assert marking_after_effect == expected
+
+
+def test_input():
+    # Given: a place, a transition, and an arc from the place to the transition
+    p = Place()
+    t = Transition()
+    arc = p >> t
+
+    # And: a marking with a token
+    token = Abstract()
+    marking = pmarking({p: {token}})
+
+    # And: an effect indicating that the token was used as input for a transition
+    effect = Input(arc, token)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: no changes to the marking are made
+    #  (Input effects only model the outputs of a transition after any arc transform, not changes to the pre-set)
+    assert marking_after_effect == marking
+
+
+def test_output():
+    # Given: a transition, a place, and an arc from the transition to the place
+    t = Transition()
+    p = Place()
+    arc = t >> p
+
+    # And: a marking where the place has a token
+    token = Abstract()
+    marking = pmarking({p: {token}})
+
+    # And: an effect indicating that another token was produced as output from a transition
+    token2 = Abstract()
+    effect = Output(arc, token2)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: no changes to the marking are made
+    #  (Output effects only model the outputs of a transition before any arc transform, not changes to the post-set)
+    assert marking_after_effect == marking
+
+
+def test_produce_place_is_not_empty():
+    # Given: a transition, a place, and an arc from the transition to the place
+    t = Transition()
+    p = Place()
+    arc = t >> p
+
+    # And: a marking where the place has a token
+    token = Abstract()
+    marking = pmarking({p: {token}})
+
+    # And: an effect indicating that another token was produced as output from a transition
+    token2 = Abstract()
+    effect = Produce(arc, token2)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: the token is added to the place
+    expected = pmarking({p: {token, token2}})
+    assert marking_after_effect == expected
+
+
+def test_produce_place_is_empty():
+    # Given: a transition, a place, and an arc from the transition to the place
+    t = Transition()
+    p = Place()
+    arc = t >> p
+
+    # And: an empty marking
+    marking = pmarking({})
+
+    # And: an effect indicating that another token was produced as output from a transition
+    token = Abstract()
+    effect = Produce(arc, token)
+
+    # When: the effect is applied to the marking
+    marking_after_effect = effect.apply_to_marking(marking)
+
+    # Then: the token is added to the place
+    expected = pmarking({p: {token}})
+    assert marking_after_effect == expected
diff --git a/tests/test_petrinet.py b/tests/test_petrinet.py
index 55b6df5..e44c608 100644
--- a/tests/test_petrinet.py
+++ b/tests/test_petrinet.py
@@ -4,6 +4,7 @@ import pytest
 # Internal imports
 from carladam import Abstract, Color, Token
 from carladam.petrinet import errors
+from carladam.petrinet.occurrence import NotEnabled, TransitionGuardRaisesException
 from carladam.petrinet.petrinet import PetriNet
 from carladam.petrinet.place import Place
 from carladam.petrinet.transition import Transition, always
@@ -90,10 +91,30 @@ def test_guard_can_prevent_transition():
     assert not net.transition_is_enabled(marking, t0)
 
 
+def test_guard_exceptions_are_raised():
+    class SomeError(Exception):
+        pass
+
+    def guard_that_raises_error(*_):
+        raise SomeError()
+
+    net = PetriNet.new(
+        p0 := Place(),
+        t0 := Transition(guard=guard_that_raises_error),
+        p1 := Place(),
+        p0 >> t0,
+        t0 >> p1,
+    )
+    marking = {p0: {Token()}}
+    with pytest.raises(TransitionGuardRaisesException) as e:
+        assert not net.transition_is_enabled(marking, t0)
+    assert isinstance(e.value.__cause__, SomeError)
+
+
 def test_transition_is_not_enabled_if_disconnected():
     net = PetriNet.new(t := Transition())
     assert not net.transition_is_enabled({}, t)
-    with pytest.raises(errors.PetriNetTransitionNotEnabled):
+    with pytest.raises(NotEnabled):
         net.marking_after_transition({}, t)