Skip to content

Added jupyter notebook integration and new visualization #129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 99 commits into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
e1c2317
Added `get_state_name`
khoda81 Mar 17, 2023
7d657fc
Fixed reordering list and tuple
khoda81 Mar 17, 2023
128d38a
Added abstract methods for visualization
khoda81 Mar 17, 2023
ae0d996
Implemented graph visualization
khoda81 Mar 17, 2023
4ca7067
Fixed svg display bug
khoda81 Mar 17, 2023
ae19ccb
Removed IpythonGraph class
khoda81 Mar 17, 2023
86a8d8c
Fixed repr error for frozenset states
khoda81 Mar 17, 2023
f9e2295
Fixed `null_node` edge tooltip
khoda81 Mar 17, 2023
6deff5d
Removed unused imports
khoda81 Mar 21, 2023
3eed0e4
Added basic visualizations from Visual Automata
khoda81 Mar 26, 2023
b73ef8d
Replaced " with '
khoda81 Mar 31, 2023
6b91b7c
Sort dependencies in requirements.txt
caleb531 Apr 1, 2023
720b71b
Update remaining files to comply with flake8/black
caleb531 Apr 1, 2023
5dd6474
Using null set symbol for empty sets
khoda81 Apr 7, 2023
1f5bcd9
Added step visualization for DFA
khoda81 Apr 7, 2023
0a2a9f5
Fixed dfa step visualization label bug
khoda81 Apr 9, 2023
c7f283e
Improved coloring for step visualization
khoda81 Apr 10, 2023
9526482
Implemented step visualization for `NFA`
khoda81 Apr 12, 2023
df9f3e4
Final touches for rebasing
khoda81 Apr 12, 2023
a48ffef
Added `get_state_name`
khoda81 Mar 17, 2023
3ee49fb
Fixed reordering list and tuple
khoda81 Mar 17, 2023
fc6b09e
Added abstract methods for visualization
khoda81 Mar 17, 2023
5e55b99
Implemented graph visualization
khoda81 Mar 17, 2023
ad144a8
Removed IpythonGraph class
khoda81 Mar 17, 2023
878de2a
Fixed `null_node` edge tooltip
khoda81 Mar 17, 2023
051808f
Removed unused imports
khoda81 Mar 21, 2023
9b883fc
Added basic visualizations from Visual Automata
khoda81 Mar 26, 2023
a4ae8d8
Added step visualization for DFA
khoda81 Apr 7, 2023
fffa1fa
Implemented step visualization for `NFA`
khoda81 Apr 12, 2023
c3fc3b4
Added back frozendict as a dependency
khoda81 Apr 14, 2023
fd8e27e
Cleaned up api added for visualization
khoda81 Apr 15, 2023
c3e58be
Changed back to `AbstractSet`
khoda81 Apr 26, 2023
ec7a977
Merge branch 'develop' into jupyter-integration
eliotwrobson Apr 29, 2023
e41dbe5
Update automaton.py
eliotwrobson Apr 29, 2023
583424e
Merge branch 'jupyter-integration' of https://github.com/khoda81/auto…
eliotwrobson Apr 29, 2023
7cc87cb
Update automaton.py
eliotwrobson Apr 29, 2023
aab3eb5
More tests passing
eliotwrobson Apr 29, 2023
36083f2
Update fa.py
eliotwrobson Apr 29, 2023
957c2d9
Update gnfa.py
eliotwrobson Apr 29, 2023
7958451
Change some types
eliotwrobson Apr 29, 2023
e36d2c6
Type fixes
eliotwrobson Apr 29, 2023
55f8120
Update nfa.py
eliotwrobson Apr 29, 2023
8513a89
Simplify get edge name function
eliotwrobson Apr 29, 2023
fc7bee3
List functions
eliotwrobson Apr 29, 2023
55bd711
Put import behind check
eliotwrobson Apr 29, 2023
165d25b
Fixed some conflicting variable names
eliotwrobson Apr 29, 2023
b00470e
Update fa.py
eliotwrobson Apr 29, 2023
e52e2bd
Started switch to pygraphviz
eliotwrobson Apr 29, 2023
01a897e
Update requirements
eliotwrobson Apr 29, 2023
479bbd2
Update requirements.txt
eliotwrobson Apr 29, 2023
7b9def2
Update tests.yml
eliotwrobson Apr 29, 2023
b212b05
Modify tests
eliotwrobson Apr 29, 2023
9f1f8af
Typing fixes
eliotwrobson Apr 29, 2023
91b0af2
Update fa.py
eliotwrobson Apr 29, 2023
d0c6452
Cleaned up display logic
eliotwrobson May 12, 2023
a48387e
Type fix
eliotwrobson May 12, 2023
b7ffb65
Type fix
eliotwrobson May 12, 2023
d8dbcae
Update nfa.py
eliotwrobson May 12, 2023
37c5e6a
Removed caching because of mypy issues
eliotwrobson May 12, 2023
119e942
Fixed GNFA freezing
eliotwrobson May 12, 2023
abb13e3
import fix
eliotwrobson May 12, 2023
5c44c0e
Update gnfa.py
eliotwrobson May 12, 2023
854b753
Updated DFA display test
eliotwrobson May 12, 2023
c8f60c4
Updated DFA display tests
eliotwrobson May 12, 2023
820136f
mypy fix
eliotwrobson May 12, 2023
1987dbe
Update test_nfa.py
eliotwrobson May 12, 2023
954b57e
Commented out GNFA test to get coverage report
eliotwrobson May 12, 2023
2322f24
lint
eliotwrobson May 12, 2023
89656e2
Updated GNFA test cases
eliotwrobson May 13, 2023
a1cd413
Fix orientation
eliotwrobson May 13, 2023
15fad78
Update gnfa.py
eliotwrobson May 13, 2023
e0bbea0
Layout typing
eliotwrobson May 13, 2023
f8ed207
Rename variable
eliotwrobson May 13, 2023
103420d
Repr fixes and better state addition
eliotwrobson May 18, 2023
e3c0563
Fixed failing test
eliotwrobson May 18, 2023
45cc69a
Reverted repr friendly value change
eliotwrobson May 18, 2023
2cd52c2
Restore missing type annotation to _get_repr_friendly_value()
caleb531 May 19, 2023
0fdf3d0
Restore type annotation to __repr__
caleb531 May 19, 2023
b0ce2ad
changed config
eliotwrobson May 19, 2023
22317ac
Update pyproject.toml
eliotwrobson May 20, 2023
7cb48f5
Added test for edge coloring
eliotwrobson May 20, 2023
6f1ec9f
nfa diagram tests
eliotwrobson May 21, 2023
a8d8433
Replace @functools.cache with @functools.lru_cache
caleb531 May 21, 2023
10706a6
Rewrote broken algorithm and added test cases
eliotwrobson Jun 6, 2023
13aaf95
lint
eliotwrobson Jun 6, 2023
e7de250
Added epsilon loop test case
eliotwrobson Jun 6, 2023
9085f24
Made test case more interesting
eliotwrobson Jun 6, 2023
5db4922
Added some test cases
eliotwrobson Jun 6, 2023
e51692b
Lint and some silly tests
eliotwrobson Jun 6, 2023
95ede78
Update test_fa.py
eliotwrobson Jun 6, 2023
668e071
More test cases
eliotwrobson Jun 6, 2023
6c01021
Add orientation test
eliotwrobson Jun 6, 2023
a6fd789
Added more complex test cases
eliotwrobson Jun 6, 2023
201f5c6
Added optimality test
eliotwrobson Jun 6, 2023
7da1013
Added figure size tests
eliotwrobson Jun 6, 2023
fddf5e8
lint
eliotwrobson Jun 6, 2023
73cd3dc
Add missing PDA test
eliotwrobson Jun 6, 2023
993536b
Remove todos
eliotwrobson Jun 14, 2023
1bf1d74
Correct typo in NFA._get_input_path() docstring
caleb531 Jun 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions automata/base/automaton.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import abc
from typing import AbstractSet, Any, Dict, Generator, Mapping, NoReturn, Tuple

from frozendict import frozendict
from typing_extensions import Self

import automata.base.config as global_config
Expand Down Expand Up @@ -124,29 +123,10 @@ def copy(self) -> Self:
"""Create a deep copy of the automaton."""
return self.__class__(**self.input_parameters)

# Format the given value for string output via repr() or str(); this exists
# for the purpose of displaying

def _get_repr_friendly_value(self, value: Any) -> Any:
"""
A helper function to convert the given value / structure into a fully
mutable one by recursively processing said structure and any of its
members, unfreezing them along the way
"""
if isinstance(value, frozenset):
return {self._get_repr_friendly_value(element) for element in value}
elif isinstance(value, frozendict):
return {
dict_key: self._get_repr_friendly_value(dict_value)
for dict_key, dict_value in value.items()
}
else:
return value

def __repr__(self) -> str:
"""Return a string representation of the automaton."""
values = ", ".join(
f"{attr_name}={self._get_repr_friendly_value(attr_value)!r}"
f"{attr_name}={attr_value!r}"
for attr_name, attr_value in self.input_parameters.items()
)
return f"{self.__class__.__qualname__}({values})"
Expand Down
79 changes: 43 additions & 36 deletions automata/fa/dfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,15 @@
Iterable,
Iterator,
List,
Literal,
Mapping,
Optional,
Set,
Tuple,
Type,
TypeVar,
cast,
)

import networkx as nx
from pydot import Dot, Edge, Node
from typing_extensions import Self

import automata.base.exceptions as exceptions
Expand Down Expand Up @@ -332,7 +329,7 @@ def minify(self, retain_names: bool = False) -> Self:
Create a minimal DFA which accepts the same inputs as this DFA.

First, non-reachable states are removed.
Then, similiar states are merged using Hopcroft's Algorithm.
Then, similar states are merged using Hopcroft's Algorithm.
retain_names: If True, merged states retain names.
If False, new states will be named 0, ..., n-1.
"""
Expand Down Expand Up @@ -1553,38 +1550,48 @@ def get_name_original(states: FrozenSet[DFAStateT]) -> DFAStateT:
final_states=dfa_final_states,
)

def show_diagram(self, path=None):
def iter_states(self):
return iter(self.states)

def iter_transitions(self):
return (
(from_, to_, symbol)
for from_, lookup in self.transitions.items()
for symbol, to_ in lookup.items()
)

def is_accepting(self, state):
return state in self.final_states

def is_initial(self, state):
return state == self.initial_state

def _get_input_path(
self, input_str
) -> tuple[list[tuple[DFAStateT, DFAStateT, DFASymbolT], bool]]:
"""
Creates the graph associated with this DFA
Calculate the path taken by input.

Args:
input_str (str): The input string to run on the DFA.

Returns:
tuple[list[tuple[DFAStateT, DFAStateT, DFASymbolT], bool]]: A list
of all transitions taken in each step and a boolean indicating
whether the DFA accepted the input.

"""
# Nodes are set of states
state_history = [
state
for state in self.read_input_stepwise(input_str, ignore_rejection=True)
]

graph = Dot(graph_type="digraph", rankdir="LR")
nodes = {}
for state in self.states:
if state == self.initial_state:
# color start state with green
if state in self.final_states:
initial_state_node = Node(
state, style="filled", peripheries=2, fillcolor="#66cc33"
)
else:
initial_state_node = Node(
state, style="filled", fillcolor="#66cc33"
)
nodes[state] = initial_state_node
graph.add_node(initial_state_node)
else:
if state in self.final_states:
state_node = Node(state, peripheries=2)
else:
state_node = Node(state)
nodes[state] = state_node
graph.add_node(state_node)
# adding edges
for from_state, lookup in self.transitions.items():
for to_label, to_state in lookup.items():
graph.add_edge(Edge(nodes[from_state], nodes[to_state], label=to_label))
if path:
graph.write_png(path)
return graph
path = [
transition
for transition in zip(state_history, state_history[1:], input_str)
]

last_state = state_history[-1] if state_history else self.initial_state
accepted = last_state in self.final_states

return path, accepted
224 changes: 223 additions & 1 deletion automata/fa/fa.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
#!/usr/bin/env python3
"""Classes and methods for working with all finite automata."""

import abc
import os
import pathlib
import typing
import uuid
from collections import defaultdict
from typing import Any, Iterable

import graphviz
from coloraide import Color

from automata.base.automaton import Automaton, AutomatonStateT

Expand All @@ -12,3 +20,217 @@ class FA(Automaton, metaclass=abc.ABCMeta):
"""An abstract base class for finite automata."""

__slots__ = tuple()

@staticmethod
def get_state_name(state_data: FAStateT) -> str:
"""
Get an string representation of a state. This is used for displaying and
uses `str` for any unsupported python data types.
"""
if isinstance(state_data, str):
if state_data == "":
return "λ"

return state_data

if isinstance(state_data, (set, frozenset, list, tuple)):
inner = ", ".join(FA.get_state_name(sub_data) for sub_data in state_data)
if isinstance(state_data, (set, frozenset)):
if state_data:
return "{" + inner + "}"
else:
return "∅"

if isinstance(state_data, tuple):
return "(" + inner + ")"

if isinstance(state_data, list):
return "[" + inner + "]"

return str(state_data)

def get_edge_name(self, symbol) -> str:
return str(symbol)

@abc.abstractmethod
def iter_states(self) -> Iterable[FAStateT]:
"""Iterate over all states in the automaton."""

@abc.abstractmethod
def iter_transitions(self) -> Iterable[tuple[FAStateT, FAStateT, Any]]:
"""
Iterate over all transitions in the automaton. Each transition is a tuple
of the form (from_state, to_state, symbol)
"""

@abc.abstractmethod
def is_accepting(self, state: FAStateT) -> bool:
"""Check if a state is an accepting state."""

@abc.abstractmethod
def is_initial(self, state: FAStateT) -> bool:
"""Check if a state is an initial state."""

def show_diagram(
self,
input_str: str | None = None,
save_path: str | os.PathLike | None = None,
*,
engine: typing.Optional[str] = None,
view=False,
cleanup: bool = True,
horizontal: bool = True,
reverse_orientation: bool = False,
fig_size: tuple = None,
font_size: float = 14.0,
arrow_size: float = 0.85,
state_separation: float = 0.5,
):
"""
Generates the graph associated with the given DFA.
Args:
input_str (str, optional): String list of input symbols. Defaults to None.
- save_path (str or os.PathLike, optional): Path to output file. If
None, the output will not be saved.
- path (str, optional): Folder path for output file. Defaults to
None.
- view (bool, optional): Storing and displaying the graph as a pdf.
Defaults to False.
- cleanup (bool, optional): Garbage collection. Defaults to True.
horizontal (bool, optional): Direction of node layout. Defaults
to True.
- reverse_orientation (bool, optional): Reverse direction of node
layout. Defaults to False.
- fig_size (tuple, optional): Figure size. Defaults to None.
- font_size (float, optional): Font size. Defaults to 14.0.
- arrow_size (float, optional): Arrow head size. Defaults to 0.85.
- state_separation (float, optional): Node distance. Defaults to 0
5.
Returns:
Digraph: The graph in dot format.
"""

# Defining the graph.
graph = graphviz.Digraph(strict=False, engine=engine)

# TODO test fig_size
if fig_size is not None:
graph.attr(size=", ".join(map(str, fig_size)))

graph.attr(ranksep=str(state_separation))
font_size = str(font_size)
arrow_size = str(arrow_size)

if horizontal:
graph.attr(rankdir="LR")
if reverse_orientation:
if horizontal:
graph.attr(rankdir="RL")
else:
graph.attr(rankdir="BT")

for state in self.iter_states():
# every edge needs an origin node, so we add a null node for every
# initial state.
if self.is_initial(state):
# we use a random uuid to make sure that the null node has a
# unique id to avoid colliding with other states and null_nodes.
null_node = str(uuid.uuid4())
graph.node(
null_node,
label="",
tooltip=".",
shape="point",
fontsize=font_size,
)
node = self.get_state_name(state)
graph.edge(
null_node,
node,
tooltip="->" + node,
arrowsize=arrow_size,
)

for state in self.iter_states():
shape = "doublecircle" if self.is_accepting(state) else "circle"
node = self.get_state_name(state)
graph.node(node, shape=shape, fontsize=font_size)

is_edge_drawn = defaultdict(lambda: False)
if input_str is not None:
path, is_accepted = self._get_input_path(input_str=input_str)

start_color = Color("#ff0")
end_color = Color("#0f0") if is_accepted else Color("#f00")
interpolation = Color.interpolate([start_color, end_color], space="srgb")

# find all transitions in the finite state machine with traversal.
for transition_index, (from_state, to_state, symbol) in enumerate(
path, start=1
):
color = interpolation(transition_index / len(path))
label = self.get_edge_name(symbol)

is_edge_drawn[from_state, to_state, symbol] = True
graph.edge(
self.get_state_name(from_state),
self.get_state_name(to_state),
label=f"<{label} <b>[<i>#{transition_index}</i>]</b>>",
arrowsize=arrow_size,
fontsize=font_size,
color=color.to_string(hex=True),
penwidth="2.5",
)

edge_labels = defaultdict(list)
for from_state, to_state, symbol in self.iter_transitions():
if is_edge_drawn[from_state, to_state, symbol]:
continue

from_node = self.get_state_name(from_state)
to_node = self.get_state_name(to_state)
label = self.get_edge_name(symbol)
edge_labels[from_node, to_node].append(label)

for (from_node, to_node), labels in edge_labels.items():
graph.edge(
from_node,
to_node,
label=",".join(sorted(labels)),
arrowsize=arrow_size,
fontsize=font_size,
)

# Write diagram to file. PNG, SVG, etc.
if save_path is not None:
save_path: pathlib.Path = pathlib.Path(save_path)

directory = save_path.parent
directory.mkdir(parents=True, exist_ok=True)
filename = save_path.stem
format = save_path.suffix.split(".")[1] if save_path.suffix else None

graph.render(
directory=directory,
filename=filename,
format=format,
cleanup=cleanup,
view=view,
)

return graph

def _get_input_path(self, input_str):
"""Calculate the path taken by input."""

raise NotImplementedError(
f"_get_input_path is not implemented for {self.__class__}"
)

def _ipython_display_(self):
# IPython is imported here because this function is only called by
# IPython. So if IPython is not installed, this function will not be
# called, therefore no need to add ipython as dependency.
from IPython.display import display

display(self.show_diagram())
Loading