Skip to content

Commit 4f299ed

Browse files
authored
fix(OutcomeCodeAndFurtherStepError): Add transitional outcome nodes (#391)
1 parent dc09b65 commit 4f299ed

24 files changed

+2684
-63
lines changed

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.13
2+
# This file is autogenerated by pip-compile with Python 3.12
33
# by the following command:
44
#
55
# pip-compile pyproject.toml

src/rebdhuhn/graph_conversion.py

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,20 @@
2323
ToNoEdge,
2424
ToYesEdge,
2525
)
26-
from rebdhuhn.models.ebd_graph import EmptyNode, TransitionEdge, TransitionNode
26+
from rebdhuhn.models.ebd_graph import (
27+
EmptyNode,
28+
TransitionalOutcomeEdge,
29+
TransitionalOutcomeNode,
30+
TransitionEdge,
31+
TransitionNode,
32+
)
2733
from rebdhuhn.models.errors import (
2834
EbdCrossReferenceNotSupportedError,
2935
EndeInWrongColumnError,
3036
OutcomeCodeAmbiguousError,
31-
OutcomeCodeAndFurtherStepError,
3237
OutcomeNodeCreationError,
3338
)
39+
from rebdhuhn.utils import assert_is_instance
3440

3541

3642
def _is_ende_with_no_code_but_note(sub_row: EbdTableSubRow) -> bool:
@@ -53,10 +59,22 @@ def _is_last_step_with_no_code_but_note(sub_row: EbdTableSubRow) -> bool:
5359
)
5460

5561

56-
def _convert_sub_row_to_outcome_node(sub_row: EbdTableSubRow) -> Optional[OutcomeNode]:
62+
def _convert_sub_row_to_outcome_node(sub_row: EbdTableSubRow) -> Optional[OutcomeNode | TransitionalOutcomeNode]:
5763
"""
5864
converts a sub_row into an outcome node (or None if not applicable)
5965
"""
66+
is_transitional_outcome = (
67+
sub_row.check_result.subsequent_step_number is not None
68+
and sub_row.check_result.subsequent_step_number not in ("Start", "Ende")
69+
and sub_row.result_code is not None
70+
and sub_row.note is not None
71+
)
72+
if is_transitional_outcome:
73+
return TransitionalOutcomeNode(
74+
result_code=assert_is_instance(sub_row.result_code, str),
75+
note=sub_row.note,
76+
subsequent_step_number=assert_is_instance(sub_row.check_result.subsequent_step_number, str),
77+
)
6078
is_cross_reference = sub_row.note is not None and sub_row.note.startswith("EBD ")
6179
is_ende_in_wrong_column = (
6280
sub_row.result_code is None and sub_row.note is not None and sub_row.note.lower().startswith("ende")
@@ -70,8 +88,6 @@ def _convert_sub_row_to_outcome_node(sub_row: EbdTableSubRow) -> Optional[Outcom
7088
if is_hinweis and sub_row.result_code is None and following_step:
7189
# We ignore Hinweise, if they are in during a decision process.
7290
return None
73-
if sub_row.check_result.subsequent_step_number is not None and sub_row.result_code is not None:
74-
raise OutcomeCodeAndFurtherStepError(sub_row=sub_row)
7591
if sub_row.result_code is not None or sub_row.note is not None and not is_cross_reference:
7692
return OutcomeNode(result_code=sub_row.result_code, note=sub_row.note)
7793
return None
@@ -121,6 +137,9 @@ def get_all_nodes(table: EbdTable) -> List[EbdGraphNode]:
121137
continue
122138
for sub_row in row.sub_rows:
123139
outcome_node = _convert_sub_row_to_outcome_node(sub_row)
140+
if isinstance(outcome_node, TransitionalOutcomeNode):
141+
result.append(outcome_node)
142+
continue
124143
if outcome_node is not None:
125144
result.append(outcome_node)
126145
if (
@@ -162,8 +181,6 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
162181
first_node_after_start = _get_key_and_node_with_lowest_step_number(table)[1]
163182
result: List[EbdGraphEdge] = [EbdGraphEdge(source=nodes["Start"], target=first_node_after_start, note=None)]
164183

165-
outcome_nodes_duplicates: dict[str, OutcomeNode] = {} # map to check for duplicate outcome nodes
166-
167184
for row in table.rows:
168185
row_node = _convert_row_to_decision_or_transition_node(row)
169186
if isinstance(row_node, TransitionNode):
@@ -179,15 +196,37 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
179196
assert isinstance(row_node, DecisionNode)
180197
for sub_row in row.sub_rows:
181198
assert isinstance(sub_row.check_result.result, bool)
182-
if sub_row.check_result.subsequent_step_number is not None and not _is_ende_with_no_code_but_note(sub_row):
183-
edge = _yes_no_transition_edge(
184-
sub_row.check_result.result,
185-
source=row_node,
186-
target=nodes[sub_row.check_result.subsequent_step_number],
199+
if (
200+
sub_row.check_result.subsequent_step_number is not None
201+
and not _is_ende_with_no_code_but_note(sub_row)
202+
and sub_row.result_code is None
203+
):
204+
result.append(
205+
_yes_no_transition_edge(
206+
sub_row.check_result.result,
207+
source=row_node,
208+
target=nodes[sub_row.check_result.subsequent_step_number],
209+
)
187210
)
188211
else:
189-
outcome_node: Optional[OutcomeNode] = _convert_sub_row_to_outcome_node(sub_row)
212+
outcome_node: Optional[OutcomeNode | TransitionalOutcomeNode] = _convert_sub_row_to_outcome_node(
213+
sub_row
214+
)
190215

216+
if isinstance(outcome_node, TransitionalOutcomeNode):
217+
result.append(
218+
_yes_no_transition_edge(
219+
sub_row.check_result.result,
220+
source=row_node,
221+
target=outcome_node,
222+
)
223+
)
224+
result.append(
225+
TransitionalOutcomeEdge(
226+
source=outcome_node, target=nodes[outcome_node.subsequent_step_number], note=None
227+
)
228+
)
229+
continue
191230
if outcome_node is None:
192231
if all(sr.result_code is None for sr in row.sub_rows) and any(
193232
sr.note is not None and sr.note.startswith("EBD ") for sr in row.sub_rows
@@ -196,26 +235,23 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
196235
raise OutcomeNodeCreationError(decision_node=row_node, sub_row=sub_row)
197236

198237
# check for ambiguous outcome nodes, i.e. A** with different notes
199-
is_ambiguous_outcome_node = (
200-
outcome_node.result_code in outcome_nodes_duplicates
201-
and not _notes_same_except_for_whitespace(
202-
outcome_nodes_duplicates[outcome_node.result_code].note, outcome_node.note
203-
)
238+
is_ambiguous_outcome_node = outcome_node.get_key() in nodes and not _notes_same_except_for_whitespace(
239+
assert_is_instance(nodes[outcome_node.get_key()], OutcomeNode).note, outcome_node.note
204240
)
205241

206-
if not is_ambiguous_outcome_node:
207-
outcome_nodes_duplicates[outcome_node.get_key()] = outcome_node
208-
else:
242+
if is_ambiguous_outcome_node:
209243
raise OutcomeCodeAmbiguousError(
210-
outcome_node1=outcome_nodes_duplicates[outcome_node.get_key()], outcome_node2=outcome_node
244+
outcome_node1=assert_is_instance(nodes[outcome_node.get_key()], OutcomeNode),
245+
outcome_node2=outcome_node,
211246
)
212247

213-
edge = _yes_no_transition_edge(
214-
sub_row.check_result.result,
215-
source=row_node,
216-
target=nodes[outcome_node.get_key()],
248+
result.append(
249+
_yes_no_transition_edge(
250+
sub_row.check_result.result,
251+
source=row_node,
252+
target=nodes[outcome_node.get_key()],
253+
)
217254
)
218-
result.append(edge)
219255
return result
220256

221257

src/rebdhuhn/graph_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from networkx import DiGraph, all_simple_paths # type:ignore[import-untyped]
1010

1111
from rebdhuhn.models import ToNoEdge, ToYesEdge
12-
from rebdhuhn.models.errors import PathsNotGreaterThanOneError
12+
from rebdhuhn.models.errors import GraphTooComplexForPlantumlError, PathsNotGreaterThanOneError
1313

1414
COMMON_ANCESTOR_FIELD = "common_ancestor_for_node"
1515
# Defines the label to annotate the last common ancestor node with the information to which node
@@ -40,6 +40,10 @@ def _mark_last_common_ancestors(graph: DiGraph) -> None:
4040
Each node which is such an ancestor will contain the information of which nodes it is the last common ancestor.
4141
It is stored in the dict field `COMMON_ANCESTOR_FIELD` as a list.
4242
"""
43+
if len(graph.nodes) > 90:
44+
raise GraphTooComplexForPlantumlError(
45+
message=f"Graph is too large to determine the last common ancestors." f"Number of Nodes: {len(graph.nodes)}"
46+
)
4347
for node in graph:
4448
in_degree: int = graph.in_degree(node)
4549
if in_degree <= 1:

src/rebdhuhn/graphviz.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77

88
from rebdhuhn.add_watermark import add_background as add_background_function
99
from rebdhuhn.add_watermark import add_watermark as add_watermark_function
10-
from rebdhuhn.graph_utils import _mark_last_common_ancestors
1110
from rebdhuhn.kroki import DotToSvgConverter
1211
from rebdhuhn.models import DecisionNode, EbdGraph, EbdGraphEdge, EndNode, OutcomeNode, StartNode, ToNoEdge, ToYesEdge
13-
from rebdhuhn.models.ebd_graph import EmptyNode, TransitionNode
12+
from rebdhuhn.models.ebd_graph import EmptyNode, TransitionalOutcomeNode, TransitionNode
1413
from rebdhuhn.utils import add_line_breaks
1514

1615
ADD_INDENT = " " #: This is just for style purposes to make the plantuml files human-readable.
@@ -131,7 +130,7 @@ def _convert_node_to_dot(ebd_graph: EbdGraph, node: str, indent: str) -> str:
131130
match ebd_graph.graph.nodes[node]["node"]:
132131
case DecisionNode():
133132
return _convert_decision_node_to_dot(ebd_graph, node, indent)
134-
case OutcomeNode():
133+
case OutcomeNode() | TransitionalOutcomeNode():
135134
return _convert_outcome_node_to_dot(ebd_graph, node, indent)
136135
case EndNode():
137136
return _convert_end_node_to_dot(node, indent)
@@ -208,7 +207,7 @@ def convert_graph_to_dot(ebd_graph: EbdGraph) -> str:
208207
Convert the EbdGraph to dot output for Graphviz. Returns the dot code as string.
209208
"""
210209
nx_graph = ebd_graph.graph
211-
_mark_last_common_ancestors(nx_graph)
210+
# _mark_last_common_ancestors(nx_graph)
212211
header = (
213212
f'<B><FONT POINT-SIZE="18">{ebd_graph.metadata.chapter}</FONT></B><BR align="left"/><BR/>'
214213
f'<B><FONT POINT-SIZE="16">{ebd_graph.metadata.section}</FONT></B><BR align="left"/><BR/><BR/><BR/>'

src/rebdhuhn/models/ebd_graph.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,31 @@ def get_key(self) -> str:
150150
return "Empty"
151151

152152

153+
@attrs.define(auto_attribs=True, kw_only=True, frozen=True) # networkx requirement: nodes are hashable (frozen=True)
154+
class TransitionalOutcomeNode(EbdGraphNode):
155+
"""
156+
An outcome node with subsequent steps.
157+
"""
158+
159+
result_code: str = attrs.field(default=None, validator=attrs.validators.matches_re(RESULT_CODE_REGEX))
160+
"""
161+
The outcome of the decision tree check; e.g. 'A55'
162+
"""
163+
subsequent_step_number: str = attrs.field(validator=attrs.validators.matches_re(r"\d+"))
164+
165+
"""
166+
The number of the subsequent step, e.g. '2' or 'Ende'. Needed for key generation.
167+
"""
168+
169+
note: Optional[str] = attrs.field(validator=attrs.validators.optional(attrs.validators.instance_of(str)))
170+
"""
171+
An optional note for this outcome; e.g. 'Cluster:Ablehnung\nFristüberschreitung'
172+
"""
173+
174+
def get_key(self) -> str:
175+
return self.result_code + "_" + self.subsequent_step_number
176+
177+
153178
@attrs.define(auto_attribs=True, kw_only=True, frozen=True)
154179
class TransitionNode(EbdGraphNode):
155180
"""
@@ -235,6 +260,20 @@ class TransitionEdge(EbdGraphEdge):
235260
"""
236261

237262

263+
@attrs.define(auto_attribs=True, kw_only=True)
264+
class TransitionalOutcomeEdge(EbdGraphEdge):
265+
"""
266+
an edge that connects a transitional outcome node from the last or to the respective next step
267+
"""
268+
269+
source: DecisionNode | TransitionalOutcomeNode = attrs.field(
270+
validator=attrs.validators.instance_of((DecisionNode, TransitionalOutcomeNode))
271+
)
272+
"""
273+
ths source which refers to the next step
274+
"""
275+
276+
238277
@attrs.define(auto_attribs=True, kw_only=True)
239278
class EbdGraph:
240279
"""

src/rebdhuhn/models/errors/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def __init__(self, outcome_node1: OutcomeNode, outcome_node2: OutcomeNode):
125125

126126
class OutcomeCodeAndFurtherStepError(NotImplementedError):
127127
"""
128-
Catches outcome nodes with further steps. This is not implemented yet.
128+
Catches outcome nodes with further steps. This is not implemented yet. This error is not raised currently.
129129
"""
130130

131131
def __init__(self, sub_row: EbdTableSubRow):

src/rebdhuhn/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""utility functions"""
22

3+
from typing import Any, TypeVar, overload
4+
35

46
def _split_string(input_string: str, max_length: int) -> list[str]:
57
"""
@@ -56,3 +58,39 @@ def add_line_breaks(text: str, max_line_length: int = 80, line_sep: str = "\n")
5658
way, whereas `...Bilanzierungs-\nverantwortung...` is just an artefact.
5759
"""
5860
return line_sep.join(_split_string(text, max_line_length))
61+
62+
63+
### taken from wanna.bee
64+
65+
T = TypeVar("T")
66+
U = TypeVar("U")
67+
V = TypeVar("V")
68+
69+
70+
@overload
71+
def assert_is_instance(obj: Any, cls1: type[T], /) -> T: ...
72+
73+
74+
@overload
75+
def assert_is_instance(obj: Any, cls1: type[T], cls2: type[U], /) -> T | U: ...
76+
77+
78+
@overload
79+
def assert_is_instance(obj: Any, cls1: type[T], cls2: type[U], cls3: type[V], /) -> T | U | V: ...
80+
81+
82+
def assert_is_instance(obj: Any, *cls: type[Any]) -> Any:
83+
"""
84+
Assert that the object is an instance of at least one of the classes.
85+
86+
For up to 5 classes (overload variants), the return value will have an appropriate type hint.
87+
88+
:param obj: The object to check.
89+
:param cls: The classes to check against.
90+
:returns: The object if it is an instance of one of the classes.
91+
:raises TypeError: If the object is not an instance of the classes.
92+
"""
93+
if not isinstance(obj, cls):
94+
raise TypeError(f"Expected {cls}, got {type(obj)}")
95+
96+
return obj
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# serializer version: 1
2+
# name: test_e0610_svg_creation[table_dot_svg_transitional_outcome_E_0610]
3+
'''
4+
digraph D {
5+
labelloc="t";
6+
label=<<B><FONT POINT-SIZE="18">GPKE</FONT></B><BR align="left"/><BR/><B><FONT POINT-SIZE="16">6.8.1: AD: Abrechnungsdaten Netznutzungsabrechnung</FONT></B><BR align="left"/><BR/><BR/><BR/>>;
7+
ratio="compress";
8+
concentrate=true;
9+
pack=true;
10+
rankdir=TB;
11+
packmode="array";
12+
size="20,20";
13+
fontsize=12;
14+
pad=0.25;
15+
"Start" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#8ba2d7", label=<<B>E_0610</B><BR align="left"/><FONT>Prüfende Rolle: <B>LF</B></FONT><BR align="center"/>>, fontname="Roboto, sans-serif"];
16+
"10" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", label=<<B>10: </B>Ergibt sich aus der Prüfung, dass der Empfänger in der Qualitätsrückmeldung<BR align="left"/>seine Sicht der Stammdaten mitteilen möchte?<BR align="left"/>Hinweis:<BR align="left"/>Dies ist für jeden in der Änderung vorhandenen Verwendungszeitraum der Daten<BR align="left"/>jeweils für den gesamten Zeitraum zu prüfen.<BR align="left"/>>, fontname="Roboto, sans-serif"];
17+
"A01_30" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c4cac1", label=<<B>A01</B><BR align="left"/><BR align="left"/><FONT>Der Empfänger übernimmt die Stammdaten.<BR align="left"/>Er teilt in der Qualitätsrückmeldung mit, dass er die Stammdaten ohne Anmerkung<BR align="left"/>übernommen hat.<BR align="left"/></FONT>>, fontname="Roboto, sans-serif"];
18+
"A02_30" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c4cac1", label=<<B>A02</B><BR align="left"/><BR align="left"/><FONT>Der Empfänger übernimmt die Stammdaten.<BR align="left"/>Er teilt mit der Qualitätsrückmeldung mit, dass diese Stammdaten aus seiner<BR align="left"/>Sicht nicht korrekt sind. Er gibt die aus seiner Sicht korrekten Stammdaten als<BR align="left"/>Qualitätsrückmeldung zurück.<BR align="left"/></FONT>>, fontname="Roboto, sans-serif"];
19+
"30" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", label=<<B>30: </B>Sind noch weitere Verwendungszeiträume zu prüfen?<BR align="left"/>>, fontname="Roboto, sans-serif"];
20+
"Ende" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#8ba2d7", label="Ende", fontname="Roboto, sans-serif"];
21+
22+
"Start" -> "10" [color="#88a0d6"];
23+
"10" -> "A01_30" [label=<<B>NEIN</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
24+
"10" -> "A02_30" [label=<<B>JA</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
25+
"A01_30" -> "30" [color="#88a0d6"];
26+
"A02_30" -> "30" [color="#88a0d6"];
27+
"30" -> "10" [label=<<B>JA</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
28+
"30" -> "Ende" [label=<<B>NEIN</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
29+
30+
bgcolor="transparent";
31+
fontname="Roboto, sans-serif";
32+
}
33+
'''
34+
# ---

0 commit comments

Comments
 (0)