diff --git a/python/automake.mk b/python/automake.mk
index f372a72a2..86ec86a91 100644
--- a/python/automake.mk
+++ b/python/automake.mk
@@ -74,6 +74,7 @@ ovs_flowviz = \
 	python/ovs/flowviz/odp/tree.py \
 	python/ovs/flowviz/ofp/__init__.py \
 	python/ovs/flowviz/ofp/cli.py \
+	python/ovs/flowviz/ofp/logic.py \
 	python/ovs/flowviz/ofp/html.py \
 	python/ovs/flowviz/ovs-flowviz \
 	python/ovs/flowviz/process.py
diff --git a/python/ovs/flowviz/ofp/cli.py b/python/ovs/flowviz/ofp/cli.py
index 28f3873b7..a0a94bd3b 100644
--- a/python/ovs/flowviz/ofp/cli.py
+++ b/python/ovs/flowviz/ofp/cli.py
@@ -12,10 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
+
 import click
 
 from ovs.flowviz.main import maincli
 from ovs.flowviz.ofp.html import HTMLProcessor
+from ovs.flowviz.ofp.logic import LogicFlowProcessor
 from ovs.flowviz.process import (
     ConsoleProcessor,
     JSONOpenFlowProcessor,
@@ -59,6 +62,116 @@ def console(opts, heat_map):
     proc.print()
 
 
+def ovn_detrace_callback(ctx, param, value):
+    """click callback to add detrace information to config object and
+    set general ovn-detrace flag to True
+    """
+    ctx.obj[param.name] = value
+    if value != param.default:
+        ctx.obj["ovn_detrace_flag"] = True
+    return value
+
+
+@openflow.command()
+@click.option(
+    "-d",
+    "--ovn-detrace",
+    "ovn_detrace_flag",
+    is_flag=True,
+    show_default=True,
+    help="Use ovn-detrace to extract cookie information (implies '-c')",
+)
+@click.option(
+    "--ovn-detrace-path",
+    default="/usr/bin",
+    type=click.Path(),
+    help="Use an alternative path to where ovn_detrace.py is located. "
+    "Instead of using this option you can just set PYTHONPATH accordingly.",
+    show_default=True,
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "--ovnnb-db",
+    default=os.getenv("OVN_NB_DB") or "unix:/var/run/ovn/ovnnb_db.sock",
+    help="Specify the OVN NB database string (implies -d). "
+    "If the OVN_NB_DB environment variable is set, it's used as default. "
+    "Otherwise, the default is unix:/var/run/ovn/ovnnb_db.sock",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "--ovnsb-db",
+    default=os.getenv("OVN_SB_DB") or "unix:/var/run/ovn/ovnsb_db.sock",
+    help="Specify the OVN NB database string (implies -d). "
+    "If the OVN_NB_DB environment variable is set, it's used as default. "
+    "Otherwise, the default is unix:/var/run/ovn/ovnnb_db.sock",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "-o",
+    "--ovn-filter",
+    help="Specify a filter to be run on ovn-detrace information (implied -d). "
+    "Format: python regular expression "
+    "(see https://docs.python.org/3/library/re.html)",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "-s",
+    "--show-flows",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Show the full flows under each logical flow",
+)
+@click.option(
+    "-c",
+    "--cookie",
+    "cookie_flag",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Consider the cookie in the logical flow",
+)
+@click.option(
+    "-h",
+    "--heat-map",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Create heat-map with packet and byte counters (when -s is used)",
+)
+@click.pass_obj
+def logic(
+    opts,
+    ovn_detrace_flag,
+    ovn_detrace_path,
+    ovnnb_db,
+    ovnsb_db,
+    ovn_filter,
+    show_flows,
+    cookie_flag,
+    heat_map,
+):
+    """
+    Print the logical structure of the flows.
+
+    First, sorts the flows based on tables and priorities.
+    Then, deduplicates logically equivalent flows: these a flows that match
+    on the same set of fields (regardless of the values they match against),
+    have the same priority, and actions (regardless of action arguments,
+    except in the case of output and recirculate).
+    Optionally, the cookie can also be considered to be part of the logical
+    flow.
+    """
+    if ovn_detrace_flag:
+        opts["ovn_detrace_flag"] = True
+    if opts.get("ovn_detrace_flag"):
+        cookie_flag = True
+
+    processor = LogicFlowProcessor(opts, cookie_flag, heat_map)
+    processor.process()
+    processor.print(show_flows)
+
+
 @openflow.command()
 @click.pass_obj
 def html(opts):
diff --git a/python/ovs/flowviz/ofp/logic.py b/python/ovs/flowviz/ofp/logic.py
new file mode 100644
index 000000000..5851153d9
--- /dev/null
+++ b/python/ovs/flowviz/ofp/logic.py
@@ -0,0 +1,303 @@
+# Copyright (c) 2023 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+import io
+import re
+
+from rich.tree import Tree
+from rich.text import Text
+
+from ovs.flowviz.process import FileProcessor
+from ovs.flowviz.console import (
+    ConsoleFormatter,
+    ConsoleBuffer,
+    hash_pallete,
+    file_header,
+    heat_pallete,
+)
+
+
+class LFlow:
+    """A Logical Flow represents the skeleton of a flow.
+
+    Two logical flows have the same logical representation if they match
+    against the same fields (regardless of the matching value) and have the
+    same set of actions (regardless of the actions' arguments, except for those
+    in the exact_actions list).
+
+    Attributes:
+        flow (OFPFlow): The flow
+        exact_actions(list): Optional; list of action keys that are
+            considered unique if the value is also the same.
+        match_cookie (bool): Optional; if cookies are part of the logical
+            flow
+    """
+
+    def __init__(self, flow, exact_actions=[], match_cookie=False):
+        self.cookie = flow.info.get("cookie") or 0 if match_cookie else None
+        self.priority = flow.match.get("priority") or 0
+        self.match_keys = tuple([kv.key for kv in flow.match_kv])
+
+        self.action_keys = tuple(
+            [
+                kv.key
+                for kv in flow.actions_kv
+                if kv.key not in exact_actions
+            ]
+        )
+        self.match_action_kvs = [
+            kv for kv in flow.actions_kv if kv.key in exact_actions
+        ]
+
+    def __eq__(self, other):
+        return (
+            (self.cookie == other.cookie if self.cookie else True)
+            and self.priority == other.priority
+            and self.action_keys == other.action_keys
+            and self.equal_match_action_kvs(other)
+            and self.match_keys == other.match_keys
+        )
+
+    def equal_match_action_kvs(self, other):
+        """ Compares the logical flow's match action key-values with the
+        others.
+
+        Args:
+            other (LFlow): The other LFlow to compare against
+
+        Returns true if both LFlow have the same action k-v.
+        """
+        if len(other.match_action_kvs) != len(self.match_action_kvs):
+            return False
+
+        for kv in self.match_action_kvs:
+            found = False
+            for other_kv in other.match_action_kvs:
+                if self.match_kv(kv, other_kv):
+                    found = True
+                    break
+            if not found:
+                return False
+        return True
+
+    def match_kv(self, one, other):
+        """Compares a KeyValue.
+        Args:
+            one, other (KeyValue): The objects to compare
+
+        Returns true if both KeyValue objects have the same key and value
+        """
+        return one.key == other.key and one.value == other.value
+
+    def __hash__(self):
+        hash_data = [
+            self.cookie,
+            self.priority,
+            self.action_keys,
+            tuple((kv.key, str(kv.value)) for kv in self.match_action_kvs),
+            self.match_keys,
+        ]
+        if self.cookie:
+            hash_data.append(self.cookie)
+        return tuple(hash_data).__hash__()
+
+    def format(self, buf, formatter):
+        """Format the Logical Flow into a Buffer."""
+        if self.cookie:
+            buf.append_extra(
+                "cookie={} ".format(hex(self.cookie)).ljust(18),
+                style=cookie_style_gen(str(self.cookie)),
+            )
+
+        buf.append_extra(
+            "priority={} ".format(self.priority), style="steel_blue"
+        )
+        buf.append_extra(",".join(self.match_keys), style="steel_blue")
+        buf.append_extra("  --->  ", style="bold magenta")
+        buf.append_extra(",".join(self.action_keys), style="steel_blue")
+
+        if len(self.match_action_kvs) > 0:
+            buf.append_extra(" ", style=None)
+
+        for kv in self.match_action_kvs:
+            formatter.format_kv(buf, kv, formatter.style)
+            buf.append_extra(",", style=None)
+
+
+class LogicFlowProcessor(FileProcessor):
+    def __init__(self, opts, match_cookie, heat_map):
+        super().__init__(opts, "ofp")
+        self.data = dict()
+        self.min_max = dict()
+        self.match_cookie = match_cookie
+        self.heat_map = ["n_packets", "n_bytes"] if heat_map else []
+        self.ovn_detrace = (
+            OVNDetrace(opts) if opts.get("ovn_detrace_flag") else None
+        )
+
+    def start_file(self, name, filename):
+        if len(self.heat_map) > 0:
+            self.min = [-1] * len(self.heat_map)
+            self.max = [0] * len(self.heat_map)
+        self.tables = dict()
+
+    def stop_file(self, name, filename):
+        if len(self.heat_map) > 0:
+            self.min_max[name] = (self.min, self.max)
+        self.data[name] = self.tables
+
+    def process_flow(self, flow, name):
+        """Sort the flows by table and logical flow."""
+        # Running calculation of min and max values for all the fields that
+        # take place in the heatmap.
+        for i, field in enumerate(self.heat_map):
+            val = flow.info.get(field)
+            if self.min[i] == -1 or val < self.min[i]:
+                self.min[i] = val
+            if val > self.max[i]:
+                self.max[i] = val
+
+        table = flow.info.get("table") or 0
+        if not self.tables.get(table):
+            self.tables[table] = dict()
+
+        # Group flows by logical hash
+        lflow = LFlow(
+            flow,
+            exact_actions=["output", "resubmit", "drop"],
+            match_cookie=self.match_cookie,
+        )
+
+        if not self.tables[table].get(lflow):
+            self.tables[table][lflow] = list()
+
+        self.tables[table][lflow].append(flow)
+
+    def print(self, show_flows):
+        formatter = ConsoleFormatter(opts=self.opts)
+        console = formatter.console
+        for name, tables in self.data.items():
+            console.print("\n")
+            console.print(file_header(name))
+            tree = Tree("Ofproto Flows (logical)")
+
+            for table_num in sorted(tables.keys()):
+                table = tables[table_num]
+                table_tree = tree.add("** TABLE {} **".format(table_num))
+
+                if len(self.heat_map) > 0 and len(table.values()) > 0:
+                    for i, field in enumerate(self.heat_map):
+                        (min_val, max_val) = self.min_max[name][i]
+                        formatter.style.set_value_style(
+                            field, heat_pallete(min_val, max_val)
+                        )
+
+                for lflow in sorted(
+                    table.keys(),
+                    key=(lambda x: x.priority),
+                    reverse=True,
+                ):
+                    flows = table[lflow]
+                    ovn_info = None
+                    if self.ovn_detrace:
+                        ovn_info = self.ovn_detrace.get_ovn_info(lflow.cookie)
+                        if self.opts.get("ovn_filter"):
+                            ovn_regexp = re.compile(
+                                self.opts.get("ovn_filter")
+                            )
+                            if not ovn_regexp.search(ovn_info):
+                                continue
+
+                    buf = ConsoleBuffer(Text())
+
+                    lflow.format(buf, formatter)
+                    buf.append_extra(
+                        " ( x {} )".format(len(flows)),
+                        style="dark_olive_green3",
+                    )
+                    lflow_tree = table_tree.add(buf.text)
+
+                    if ovn_info:
+                        ovn = lflow_tree.add("OVN Info")
+                        for part in ovn_info.split("\n"):
+                            if part.strip():
+                                ovn.add(part.strip())
+
+                    if show_flows:
+                        for flow in flows:
+                            buf = ConsoleBuffer(Text())
+                            highlighted = None
+                            if self.opts.get("highlight"):
+                                result = self.opts.get("highlight").evaluate(
+                                    flow
+                                )
+                                if result:
+                                    highlighted = result.kv
+                            formatter.format_flow(buf, flow, highlighted)
+                            lflow_tree.add(buf.text)
+
+            console.print(tree)
+
+
+class OVNDetrace(object):
+    def __init__(self, opts):
+        if not opts.get("ovn_detrace_flag"):
+            raise Exception("Cannot initialize OVN Detrace connection")
+
+        if opts.get("ovn_detrace_path"):
+            sys.path.append(opts.get("ovn_detrace_path"))
+
+        import ovn_detrace
+
+        class FakePrinter(ovn_detrace.Printer):
+            def __init__(self):
+                self.buff = io.StringIO()
+
+            def print_p(self, msg):
+                print("  * ", msg, file=self.buff)
+
+            def print_h(self, msg):
+                print("   * ", msg, file=self.buff)
+
+            def clear(self):
+                self.buff = io.StringIO()
+
+        self.ovn_detrace = ovn_detrace
+        self.ovnnb_conn = ovn_detrace.OVSDB(
+            opts.get("ovnnb_db"), "OVN_Northbound"
+        )
+        self.ovnsb_conn = ovn_detrace.OVSDB(
+            opts.get("ovnsb_db"), "OVN_Southbound"
+        )
+        self.ovn_printer = FakePrinter()
+        self.cookie_handlers = ovn_detrace.get_cookie_handlers(
+            self.ovnnb_conn, self.ovnsb_conn, self.ovn_printer
+        )
+
+    def get_ovn_info(self, cookie):
+        self.ovn_printer.clear()
+        self.ovn_detrace.print_record_from_cookie(
+            self.ovnsb_conn, self.cookie_handlers, "{:x}".format(cookie)
+        )
+        return self.ovn_printer.buff.getvalue()
+
+
+# Try to make it easy to spot same cookies by printing them in different
+# colors
+cookie_style_gen = hash_pallete(
+    hue=[x / 10 for x in range(0, 10)],
+    saturation=[0.5],
+    value=[0.5 + x / 10 * (0.85 - 0.5) for x in range(0, 10)],
+)
