Repository: Gurobi/gurobi-logtools (language: python)

Issue:
Parse deterministic work metric
Implements https://github.com/Gurobi/grblogtools/issues/27 for nodelog, barrier, and simplex.
Tests seem to pass but a pre-commit hook is failing (I think)...


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 05c6964..6b91ba9 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -27,7 +27,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install build
+        python -m pip install build
     - name: Build package
       run: python -m build
     - name: Publish package
diff --git a/.github/workflows/python-tox.yml b/.github/workflows/python-tox.yml
index e31257a..ac834ee 100644
--- a/.github/workflows/python-tox.yml
+++ b/.github/workflows/python-tox.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/.gitignore b/.gitignore
index 6d34ac5..58954af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,3 +115,6 @@ dmypy.json
 
 # Cython debug symbols
 cython_debug/
+
+# Produced by tox tests
+test.xlsx
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..8eb84de
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,25 @@
+repos:
+    - repo: https://github.com/pre-commit/pre-commit-hooks
+      rev: v4.1.0
+      hooks:
+        - id: end-of-file-fixer
+        - id: trailing-whitespace
+
+    - repo: https://github.com/myint/autoflake
+      rev: v1.4
+      hooks:
+        - id: autoflake
+          files: (^(src|tests)/)|(^[^/]*$)
+          args: [--in-place, --remove-all-unused-imports, --recursive, --ignore-init-module-imports]
+
+    - repo: https://github.com/psf/black
+      rev: 21.12b0
+      hooks:
+        - id: black
+          additional_dependencies: ["Click<=8.0.4"]
+
+    - repo: https://github.com/pycqa/isort
+      rev: 5.10.1
+      hooks:
+        - id: isort
+          args: ["--profile", "black", "--filter-files"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c942e29..e1cd22e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@
 ### Changed
 ### Removed
 
+Refactor changes (see test_refactor_regression.py):
+
+- Seed column always gets an integer type (not object)
+- Time always gets a pandas datetime type (not a string)
+- Removed merged_logs flag for get_dataframe. Added LogNumber column to
+  disambiguate different logs in a single file
+- Added parse(...).summary() API
+
 ## 1.3.2 - 2022-01-12
 ### Fixed
 - fix error when log contains cuts with the name containing a hyphen
diff --git a/CITATION.cff b/CITATION.cff
index 2f55d86..7faf577 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -4,7 +4,7 @@ cff-version: "1.1.0"
 message: "If you use this software, please cite it using these metadata."
 title: grblogtools
 abstract: "Tools to extract and explore information from Gurobi log files."
-authors: 
+authors:
   - affiliation: "Gurobi Optimization"
     family-names: Miltenberger
     given-names: Matthias
@@ -16,5 +16,12 @@ authors:
   - affiliation: "Gurobi Optimization"
     family-names: Siefen
     given-names: Kostja
+  - affiliation: "Gurobi Optimization"
+    family-names: Aramon
+    given-names: Maliheh
+  - affiliation: "Gurobi Optimization"
+    family-names: Bowly
+    given-names: Simon
+    -orcid: https://orcid.org/0000-0002-2070-1440
 license: MIT
-repository-code: "https://github.com/Gurobi/grblogtools"
\ No newline at end of file
+repository-code: "https://github.com/Gurobi/grblogtools"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100755
index 0000000..891f221
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,45 @@
+# Contributing to grblogtools
+
+Welcome to Gurobi grblogtools!
+
+We value your experience in using grblogtools and would like to encourage you to
+contribute directly to this project.
+
+## How to report bugs or submit feature requests
+If you encounter a bug, or you think there is a need for a new feature, we recommend to
+first add the bug report or feature request to the grblogtools' [GitHub issue
+tracker](https://github.com/Gurobi/grblogtools/issues).
+
+It would be great if you add a minimal reproducible example when reporting a bug, or
+include reasoning on how the new requested feature improves the grblogtools.
+
+## Submitting changes
+We welcome your contribution in directly tackling some of the issues.
+
+We use the GitHub pull request workflow. Once your pull request is ready for review, one
+of the core maintainers of grblogtools will review your pull request.
+
+A pull request should contain tests for the changes made to the code behavior, should
+include a clear message outlining the changes done, and should be linked to an existing
+issue.
+
+Before submitting a pull request:
+- install the [pre-commit](https://pre-commit.com) package to enable the automatic
+  running of the pre-commit hooks in the `.pre-commit-configuration.yaml` file,
+- make sure all tests pass by running `pytest` in the root folder of the `grblogtools`.
+
+After a pull request is submitted, the tests will be run automatically, and the status
+will appear on the pull request page. If the tests failed, there is a link which can be
+used to debug the failed tests.
+
+## Code reviews
+The pull request author should respond to all comments received. If the
+comment has been accepted and appropriate changes applied, the author should respond by
+a short message such as "Done" and then resolve the comment. If more discussion is
+needed on a comment, it should remain open until a solution can be figured out.
+
+## Merging changes
+
+Explicit approval and passing tests are required before merging. The pull request author
+should always merge via "Squash and Merge" and the remote pull request branch should be
+deleted.
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 04024e8..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,1 +0,0 @@
-recursive-include src/grblogtools/defaults *.json
diff --git a/README.md b/README.md
index 9f3a775..39c1871 100644
--- a/README.md
+++ b/README.md
@@ -36,25 +36,27 @@ Additionally, there is a [Gurobi TechTalk demonstrating how to use grblogtools (
     ```Python
     import grblogtools as glt
 
-    summary, timelines = glt.get_dataframe(["run1/*.log", "run2/*.log"], timelines=True)
+    results = glt.parse(["run1/*.log", "run2/*.log"])
+    summary = results.summary()
+    nodelog_progress = results.progress("nodelog")
     ```
     Depending on your requirements, you may need to filter or modify the resulting DataFrames.
 
 2. draw interactive charts, preferably in a [Jupyter Notebook](https://jupyter.org/):
-    
+
     - final results from the individual runs:
     ```Python
     glt.plot(summary, type="box")
     ```
-    
+
     - progress charts for the individual runs:
     ```Python
-    glt.plot(timelines['nodelog'], y="Gap", color="Log", type="line")
+    glt.plot(nodelog_progress, y="Gap", color="Log", type="line")
     ```
 
     - progress of the norel heuristic (note, the time recorded here is since the start of norel, and does not include presolve + read time):
     ```Python
-    glt.plot(timelines['norel'], x="Time", y="Incumbent", color="Log", type="line")
+    glt.plot(result.progress("norel"), x="Time", y="Incumbent", color="Log", type="line")
     ```
 
     These are just examples using the [Plotly Python library](https://plotly.com/python/) - of course, any other plotting library of your choice can be used to work with these DataFrames.
diff --git a/grblogtools.ipynb b/grblogtools.ipynb
index 355dd49..87e3ece 100644
--- a/grblogtools.ipynb
+++ b/grblogtools.ipynb
@@ -2,6 +2,7 @@
  "cells": [
   {
    "cell_type": "markdown",
+   "id": "377c45a5",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -15,6 +16,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "89470485",
    "metadata": {},
    "source": [
     "\n",
@@ -49,6 +51,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "f8e3723b",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -64,6 +67,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "271eb577",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -78,6 +82,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "a65c272b",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -86,11 +91,13 @@
    "outputs": [],
    "source": [
     "import grblogtools as glt\n",
-    "summary = glt.get_dataframe([\"data/912-glass4-0.log\"])"
+    "\n",
+    "summary = glt.parse(\"data/912-glass4-0.log\").summary()"
    ]
   },
   {
    "cell_type": "markdown",
+   "id": "6db38759",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -103,6 +110,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "706a5aa4",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -115,6 +123,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "076b5225",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -129,6 +138,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "f862cda9",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -136,11 +146,12 @@
    },
    "outputs": [],
    "source": [
-    "summary = glt.get_dataframe([\"data/912*.log\"])"
+    "summary = glt.parse(\"data/912*.log\").summary()"
    ]
   },
   {
    "cell_type": "markdown",
+   "id": "f4759679",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -153,6 +164,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "5ade53e9",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -165,6 +177,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "1f03d7cf",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -181,6 +194,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "946ac5f2",
    "metadata": {
     "scrolled": false,
     "slideshow": {
@@ -194,6 +208,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "0e3bd341",
    "metadata": {
     "slideshow": {
      "slide_type": "-"
@@ -205,6 +220,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "5258d89a",
    "metadata": {
     "slideshow": {
      "slide_type": "subslide"
@@ -217,6 +233,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "84cd132e",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -233,6 +250,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "bf9dad83",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -247,6 +265,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "60553c5f",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -254,11 +273,12 @@
    },
    "outputs": [],
    "source": [
-    "summary, timelines = glt.get_dataframe([\"data/912*.log\"], timelines=True)"
+    "results = glt.parse(\"data/912*.log\")"
    ]
   },
   {
    "cell_type": "markdown",
+   "id": "9131409e",
    "metadata": {
     "slideshow": {
      "slide_type": "subslide"
@@ -271,6 +291,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "c342b0bc",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -278,12 +299,13 @@
    },
    "outputs": [],
    "source": [
-    "timelines[\"nodelog\"]"
+    "results.progress(\"nodelog\")"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "e25d81cc",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -291,11 +313,12 @@
    },
    "outputs": [],
    "source": [
-    "timelines[\"rootlp\"]"
+    "results.progress(\"rootlp\")"
    ]
   },
   {
    "cell_type": "markdown",
+   "id": "c569688c",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -330,6 +353,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "b12bd7ee",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -337,12 +361,13 @@
    },
    "outputs": [],
    "source": [
-    "nl = timelines[\"nodelog\"]\n",
+    "nl = results.progress(\"nodelog\")\n",
     "glt.plot(nl[nl[\"Log\"] == \"912-Cuts0-Heuristics0.1\"], title=\"912-Cuts0-Heuristics0.1\", type=\"line\", y=\"Gap\", color=\"Seed\", log_x=True)"
    ]
   },
   {
    "cell_type": "markdown",
+   "id": "d72b703e",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -356,6 +381,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "0e1eb801",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -370,6 +396,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "2546bf85",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -391,6 +418,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "1649d383",
    "metadata": {
     "slideshow": {
      "slide_type": "subslide"
@@ -403,6 +431,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "fe3819b3",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -435,6 +464,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "1341d88c",
    "metadata": {
     "slideshow": {
      "slide_type": "subslide"
@@ -447,6 +477,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "b9e5641f",
    "metadata": {
     "slideshow": {
      "slide_type": "fragment"
@@ -460,6 +491,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "d7bcc51e",
    "metadata": {
     "slideshow": {
      "slide_type": "slide"
@@ -474,6 +506,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "88419423",
    "metadata": {
     "slideshow": {
      "slide_type": "-"
@@ -486,6 +519,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "6459cde5",
    "metadata": {},
    "source": [
     "Create additional timelines and node log tables in separate worksheets using option `--timelines`.\n",
@@ -499,8 +533,9 @@
    "hash": "97a39e22a74057e77e09ffc7f5ef791bf0c7aa358a1c96c3109262f57533050a"
   },
   "kernelspec": {
-   "display_name": "Python 3.8.5 64-bit ('gurobi': conda)",
-   "name": "python3"
+   "display_name": "grblogtools",
+   "language": "python",
+   "name": "grblogtools"
   },
   "language_info": {
    "codemirror_mode": {
@@ -512,7 +547,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.5"
+   "version": "3.10.3"
   }
  },
  "nbformat": 4,
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9787c3b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..6c46dd7
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,40 @@
+[metadata]
+name = grblogtools
+version = attr: grblogtools.__version__
+description = Gurobi log file tools for parsing and exploring data
+long_description = file: README.md
+long_description_content_type = text/markdown
+author = Gurobi Optimization, LLC
+author_email = support@gurobi.com
+license = Apache-2.0
+license_file = LICENSE
+platforms = any
+url = https://github.com/Gurobi/grblogtools
+classifiers =
+    License :: OSI Approved :: Apache Software License
+    Operating System :: OS Independent
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3 :: Only
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
+
+[options]
+package_dir =
+    =src
+packages = find:
+python_requires = >=3.7
+install_requires =
+    ipywidgets
+    numpy
+    pandas
+    plotly
+    xlsxwriter
+include_package_data = True
+
+[options.packages.find]
+where = src
+
+[options.package_data]
+grblogtools.parameters.data = *.json
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 91a6fd2..0000000
--- a/setup.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from setuptools import setup, find_packages
-import os, re
-
-with open(os.path.join("src", "grblogtools", "__init__.py")) as initfile:
-    (version,) = re.findall('__version__ = "(.*)"', initfile.read())
-
-with open("README.md", "r", encoding="utf-8") as fh:
-    long_description = fh.read()
-
-setup(
-    name="grblogtools",
-    version=version,
-    author="Gurobi Optimization, LLC",
-    description="Gurobi log file tools for parsing and data exploration",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    platforms=["Windows", "Linux", "macOS"],
-    url="https://github.com/Gurobi/grblogtools",
-    package_dir={"": "src"},
-    packages=find_packages(where="src"),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: Apache Software License",
-        "Operating System :: OS Independent",
-    ],
-    install_requires=["ipywidgets", "numpy", "pandas", "plotly", "xlsxwriter"],
-    python_requires=">=3.6",
-    include_package_data=True,
-)
diff --git a/src/grblogtools/__init__.py b/src/grblogtools/__init__.py
index fd0eec5..0818c04 100644
--- a/src/grblogtools/__init__.py
+++ b/src/grblogtools/__init__.py
@@ -1,3 +1,4 @@
-__version__ = "1.3.2"
+__version__ = "2.0.0"
 
-from .grblogtools import *
+from grblogtools.api import get_dataframe, parse
+from grblogtools.plotting import plot
diff --git a/src/grblogtools/__main__.py b/src/grblogtools/__main__.py
index c238335..faf913d 100644
--- a/src/grblogtools/__main__.py
+++ b/src/grblogtools/__main__.py
@@ -1,7 +1,7 @@
-from .cli import cli
-from . import get_dataframe, __version__
+from grblogtools import __version__, parse
+from grblogtools.cli import cli
 
 cli(
-    get_dataframe=get_dataframe,
+    glt_parse=parse,
     argparse_kwargs=dict(description="grblogtools " + __version__, prog="grblogtools"),
 )
diff --git a/src/grblogtools/api.py b/src/grblogtools/api.py
new file mode 100644
index 0000000..e7c760f
--- /dev/null
+++ b/src/grblogtools/api.py
@@ -0,0 +1,186 @@
+"""Top level API for parsing log files.
+
+Usage example:
+    import grblogtools.api as glt
+    result = glt.parse("data/*.log")
+    result.summary()
+    result.progress(section="nodelog")
+
+OR, use
+    summary = glt.get_dataframe("data/*.log", timeline=False)
+    summary, timeline = glt.get_dataframe("data/*.log", timeline=True)
+"""
+
+import glob
+import itertools
+from pathlib import Path
+from typing import List, Union
+
+import pandas as pd
+
+from grblogtools.helpers import (
+    add_categorical_descriptions,
+    fill_default_parameters_nosuffix,
+    strip_model_and_seed,
+)
+from grblogtools.parsers.single_log import SingleLogParser
+
+
+class ParseResult:
+    def __init__(self):
+        self.parsers = []
+        self._common = None
+
+    def progress(self, section="nodelog") -> dict:
+        """Return the search progress for the given section in the log.
+
+        Args:
+            section (str): Possible values are norel, rootlp, and nodelog. Defaults
+                to nodelog.
+
+        Returns:
+            pd.DataFrame: A data frame representing the progress of the given section
+                in the log.
+        """
+        progress = []
+        for logfile, lognumber, parser in self.parsers:
+
+            if section == "nodelog":
+                log = parser.nodelog_parser.get_progress()
+            elif section == "rootlp":
+                log = parser.continuous_parser.get_progress()
+            elif section == "norel":
+                log = parser.norel_parser.get_progress()
+            else:
+                raise ValueError(f"Unknown section '{section}'")
+
+            progress.append(
+                pd.DataFrame(log).assign(LogFilePath=logfile, LogNumber=lognumber)
+            )
+
+        return pd.merge(
+            left=pd.concat(progress),
+            right=self.common_log_data(),
+            how="left",
+            on=["LogFilePath", "LogNumber"],
+        )
+
+    def common_log_data(self):
+        """Extract common data to be joined to progress and summary dataframes.
+
+        This could be cached in future and invalidated by .parse().
+        """
+        common = pd.DataFrame(
+            [
+                {
+                    "LogFilePath": logfile,
+                    "LogNumber": lognumber,
+                    "ModelFilePath": parser.header_parser.get_summary().get(
+                        "ModelFilePath"
+                    ),
+                    "Seed": parser.header_parser.get_parameters().get("Seed", 0),
+                    "Version": parser.header_parser.get_summary().get("Version"),
+                }
+                for logfile, lognumber, parser in self.parsers
+            ]
+        )
+        common = common.dropna(axis="columns", how="all")
+        if "ModelFilePath" in common:
+            common = common.assign(
+                ModelFile=lambda df: df["ModelFilePath"].apply(
+                    lambda p: None if p is None else Path(p).parts[-1].partition(".")[0]
+                ),
+                Model=lambda df: df["ModelFile"],
+                Log=lambda df: df.apply(strip_model_and_seed, axis=1),
+            )
+        return common
+
+    def summary(self, prettyparams=False):
+        """Construct and return a summary dataframe for all parsed logs."""
+        summary = pd.DataFrame(
+            [
+                dict(parser.get_summary(), LogFilePath=logfile, LogNumber=lognumber)
+                for logfile, lognumber, parser in self.parsers
+            ]
+        )
+        parameters = pd.DataFrame(
+            [parser.header_parser.get_parameters() for _, _, parser in self.parsers]
+        )
+        # Fill defaults and add suffix to parameter columns.
+        parameters = (
+            fill_default_parameters_nosuffix(parameters.join(summary["Version"]))
+            .drop(columns=["Version", "Seed"], errors="ignore")
+            .rename(columns=lambda c: c + " (Parameter)")
+        )
+        # Convert parameters to categorical if required.
+        if prettyparams:
+            parameters = add_categorical_descriptions(parameters)
+        summary = summary.join(parameters)
+        summary = pd.merge(
+            left=summary.drop(columns=["ModelFilePath", "Version"], errors="ignore"),
+            right=self.common_log_data(),
+            how="left",
+            on=["LogFilePath", "LogNumber"],
+        )
+        return summary
+
+    def parse(self, logfile: str) -> None:
+        """Parse a single file. The log file may contain multiple run logs."""
+        parser = SingleLogParser()
+        subsequent = SingleLogParser()
+        lognumber = 1
+        with open(logfile) as infile:
+            lines = iter(infile)
+            for line in lines:
+                if not parser.parse(line):
+                    assert not subsequent.started
+                    if subsequent.parse(line):
+                        # The current parser did not match but an empty parser
+                        # matched a header line.
+                        self.parsers.append((logfile, lognumber, parser))
+                        lognumber += 1
+                        parser = subsequent
+                        subsequent = SingleLogParser()
+
+        self.parsers.append((logfile, lognumber, parser))
+
+
+def parse(patterns: Union[str, List[str]]) -> ParseResult:
+    """Main entry point function.
+
+    Args:
+        patterns (str): a single glob pattern, or list of patterns, matching
+        log files.
+
+    """
+    result = ParseResult()
+    if type(patterns) is str:
+        patterns = [patterns]
+    logfiles = itertools.chain(*(glob.glob(pattern) for pattern in patterns))
+    for logfile in sorted(set(logfiles)):
+        result.parse(logfile)
+    return result
+
+
+def get_dataframe(logfiles: List[str], timelines=False, prettyparams=False):
+    """Compatibility function for the legacy API.
+
+    If one log file contains more than one run, all runs are parsed, each reported
+    as a separate row in the summary and timelines dataframes.
+
+    Args:
+        logfiles (str): A list of glob patterns of log files to be parsed.
+        timelines (bool, optional): Return the norel, the relaxation, and the
+            search tree progress if set to True. Defaults to False.
+        prettyparams (bool, optional): Replace some parameter values with
+            categorical labels.
+    """
+    result = parse(logfiles)
+    summary = result.summary(prettyparams=prettyparams)
+    if not timelines:
+        return summary
+    return summary, dict(
+        norel=result.progress("norel"),
+        rootlp=result.progress("rootlp"),
+        nodelog=result.progress("nodelog"),
+    )
diff --git a/src/grblogtools/cli.py b/src/grblogtools/cli.py
index 5e10e5d..8bc845e 100644
--- a/src/grblogtools/cli.py
+++ b/src/grblogtools/cli.py
@@ -1,37 +1,35 @@
 import argparse
+
 import pandas as pd
 
 
-def cli(get_dataframe, argparse_kwargs):
+def cli(glt_parse, argparse_kwargs):
+    """Entry point function for command line interface
+
+    Args:
+        glt_parse: the parse function from grblogtools (in case overloads are needed)
+        argparse_kwargs: extra info to pass to argparse
 
+    """
     parser = argparse.ArgumentParser(**argparse_kwargs)
     parser.add_argument("outfile", help="Output file name (.xlsx)", metavar="XLSXFILE")
-    parser.add_argument("logfiles", help="Gurobi log files", nargs="+", metavar="LOGFILE")
+    parser.add_argument(
+        "logfiles", help="Gurobi log files", nargs="+", metavar="LOGFILE"
+    )
     parser.add_argument(
         "-t",
         "--timelines",
         action="store_true",
         help="also store timelines (root LP, node log, and NoRel log) in separate sheets",
     )
-    parser.add_argument(
-        "-m",
-        "--mergedlogs",
-        action="store_true",
-        help="log files contain multiple logs to be extracted",
-    )
     args = parser.parse_args()
 
-    if args.timelines:
-        summary, timelines = get_dataframe(
-            args.logfiles, timelines=True, merged_logs=args.mergedlogs
-        )
-        with pd.ExcelWriter(args.outfile) as writer:
-            summary.to_excel(writer, sheet_name="Summary")
-            for tl in timelines:
-                timelines[tl].to_excel(writer, sheet_name=tl)
-    else:
-        summary = get_dataframe(args.logfiles, merged_logs=args.mergedlogs)
-        with pd.ExcelWriter(args.outfile) as writer:
-            summary.to_excel(writer, sheet_name="Summary")
+    result = glt_parse(args.logfiles)
+    summary = result.summary()
+
+    with pd.ExcelWriter(args.outfile) as writer:
+        summary.to_excel(writer, sheet_name="Summary")
+        for tl in ["norel", "rootlp", "nodelog"]:
+            result.progress(tl).to_excel(writer, sheet_name=tl)
 
     print(f"extracted {len(summary)} log(s) to {args.outfile}")
diff --git a/src/grblogtools/grblogtools.py b/src/grblogtools/grblogtools.py
deleted file mode 100755
index 95a965e..0000000
--- a/src/grblogtools/grblogtools.py
+++ /dev/null
@@ -1,968 +0,0 @@
-import glob
-import os
-import re
-import ipywidgets as widgets
-from ipywidgets import interact
-import plotly.express as px
-import pandas as pd
-from numpy import nan
-
-from grblogtools.helpers import fill_default_parameters
-from grblogtools.helpers import add_categorical_descriptions
-
-# Log Status Codes
-class logstatus:
-    FINISHED, INCOMPLETE, FAILED = range(3)
-
-    def str(value):
-        if value == logstatus.FINISHED:
-            return "FINISHED"
-        elif value == logstatus.INCOMPLETE:
-            return "INCOMPLETE"
-        elif value == logstatus.FAILED:
-            return "FAILED"
-        else:
-            return "UNKNOWN"
-
-
-class logpattern:
-
-    # Log start indicator (all lines before last occcurence are discarded)
-    headers = [
-        re.compile(
-            "Gurobi (?P<Version>\d{1,2}\.[^\s]+) \((?P<Platform>[^\)]+)\) logging started (?P<Time>.*)$"
-        ),
-        re.compile("Logging started (?P<Time>.*)$"),
-        re.compile(
-            "Gurobi Compute Server Worker version (?P<Version>\d{1,2}\.[^\s]+) build (.*) \((?P<Platform>[^\)]+)\)$"
-        ),
-        re.compile("Compute Server job ID: (?P<JobID>.*)$")
-    ]
-
-    # Parameter settings
-    parameters = re.compile("Set parameter (?P<Name>[^\s]+) to value (?P<Value>.*)$")
-
-    # Termination messsages
-    termination_message = [
-        re.compile(
-            "(Solved|Stopped) in (?P<IterCount>[^\s]+) iterations and (?P<Runtime>[^\s]+) seconds"
-        ),
-        re.compile(
-            "Best objective (?P<ObjVal>[^,]+), best bound (?P<ObjBound>[^,]+), gap (?P<MIPGap>.*)$"
-        ),
-        re.compile(
-            "Barrier solved model in (?P<BarIterCount>[^\s]+) iterations and (?P<Runtime>[^\s]+) seconds"
-        ),
-        re.compile("ERROR (?P<ErrorCode>[^:]+): (?P<ErrorMessage>.*)$"),
-        re.compile("\[(?P<ErrorMessage>process terminated with exit code [^\\]]+)\]$"),
-    ]
-
-    # Termination status
-    termination_status = [
-        re.compile("(?P<TIME_LIMIT>Time limit reached)"),
-        re.compile("(?P<OPTIMAL>Optimal solution found)(?: \(tolerance .*\))"),
-        re.compile("(?P<OPTIMAL>Optimal objective\s+(?P<ObjVal>.*))$"),
-        re.compile("(?P<ITERATION_LIMIT>Iteration limit reached)"),
-        re.compile("(?P<INF_OR_UNBD>Infeasible or unbounded model)"),
-        re.compile("(?P<INF_OR_UNBD>Model is infeasible or unbounded)"),
-        re.compile("(?P<UNBOUNDED>Unbounded model)"),
-        re.compile("(?P<UNBOUNDED>Model is unbounded)"),
-        re.compile("(?P<INFEASIBLE>Infeasible model)"),
-        re.compile("(?P<INFEASIBLE>Model is infeasible)"),
-        re.compile("(?P<SOLUTION_LIMIT>Solution limit reached)"),
-        re.compile("(?P<NODE_LIMIT>Node limit reached)"),
-        re.compile("(?P<NUMERIC>Numeric error)"),
-        re.compile("(?P<NUMERIC>Numerical trouble encountered)"),
-        re.compile(
-            "(?P<SUBOPTIMAL>Sub-optimal termination)(?: - objective (P<ObjVal>.*))$"
-        ),
-        re.compile("(?P<CUTOFF>Model objective exceeds cutoff)"),
-        re.compile("(?P<CUTOFF>Objective cutoff exceeded)"),
-        re.compile("(?P<USER_OBJ_LIMIT>Optimization achieved user objective limit)"),
-        re.compile(
-            "(?P<INTERRUPTED>(Interrupt request received|Solve interrupted))(?: \\(error code (?P<ErrorCode>[^\\)]+)\\))?"
-        ),
-    ]
-
-    # Coefficient statistics
-    coefficients = [
-        re.compile(
-            "\s*QMatrix range\s*\[(?P<MinQCCoeff>[^,]+),\s*(?P<MaxQCCoeff>[^\]]+)\]"
-        ),
-        re.compile(
-            "\s*QLMatrix range\s*\[(?P<MinQCLCoeff>[^,]+),\s*(?P<MaxQCLCoeff>[^\]]+)\]"
-        ),
-        re.compile("\s*Matrix range\s*\[(?P<MinCoeff>[^,]+),\s*(?P<MaxCoeff>[^\]]+)\]"),
-        re.compile(
-            "\s*QObjective range\s*\[(?P<MinQObjCoeff>[^,]+),\s*(?P<MaxQObjCoeff>[^\]]+)\]"
-        ),
-        re.compile(
-            "\s*Objective range\s*\[(?P<MinObjCoeff>[^,]+),\s*(?P<MaxObjCoeff>[^\]]+)\]"
-        ),
-        re.compile("\s*Bounds range\s*\[(?P<MinBound>[^,]+),\s*(?P<MaxBound>[^\]]+)\]"),
-        re.compile("\s*RHS range\s*\[(?P<MinRHS>[^,]+),\s*(?P<MaxRHS>[^\]]+)\]"),
-        re.compile("\s*QRHS range\s*\[(?P<MinQCRHS>[^,]+),\s*(?P<MaxQCRHS>[^\]]+)\]"),
-    ]
-
-    # Various pattern (last occurence is always taken)
-    various = [
-        re.compile(
-            "Variable types: (?P<PresolvedNumConVars>\d+) continuous, (?P<PresolvedNumIntVars>\d+) integer \((?P<PresolvedNumBinVars>\d+) binary\)$"
-        ),
-        re.compile(
-            "Variable types: (?P<PresolvedNumBinVars>\d+) bin/(?P<PresolvedNumIntVars>\d+) gen[^/]*/(?P<PresolvedNumConVars>\d+) continuous"
-        ),
-        re.compile(
-            "Semi-Variable types: (?P<PresolvedNumSemiContVars>\d+) continuous, (?P<PresolvedNumSemiIntVars>\d+) integer$"
-        ),
-        re.compile("Optimal objective\s+(?P<ObjVal>.*)$"),
-        re.compile(
-            "Optimize a model with (?P<NumConstrs>\d+) (R|r)ows, (?P<NumVars>\d+) (C|c)olumns and (?P<NumNZs>\d+) (N|n)on(Z|z)ero(e?)s"
-        ),
-        re.compile("Presolve time: (?P<PresolveTime>[\d\.]+)s"),
-        re.compile("Thread count was (?P<Threads>\d+) \(of (?P<Cores>\d+) available processors\)"),
-        re.compile("Distributed MIP job count: (?P<DistributedMIPJobs>\d+)"),
-        re.compile("Concurrent MIP job count: (?P<ConcurrentJobs>\d+)"),
-        re.compile("Reading time = (?P<ReadTime>[\d\.]+) seconds"),
-        re.compile("Ordering time: (?P<OrderingTime>[\d\.]+)s"),
-        re.compile("Model has (?P<NumQNZs>\d+) quadratic objective terms?"),
-        re.compile("Model has (?P<NumQConstrs>\d+) quadratic constraints?"),
-        re.compile("Model has (?P<NumSOS>\d+) SOS constraints?"),
-        re.compile(
-            "Model has (?P<NumPWLObjVars>\d+) piecewise-linear objective terms?"
-        ),
-        re.compile("Model has (?P<NumGenConstrs>\d+) general constraints?"),
-        re.compile("Loaded user MIP start with objective (?P<MIPStart>.*)$"),
-        re.compile("Pool objective bound (?P<PoolObjBound>.*)$"),
-        re.compile("Solution count (?P<SolCount>\d+)"),
-        re.compile(
-            "Root relaxation: objective (?P<RelaxObj>[^,]+), (?P<RelaxIterCount>\d+) iterations, (?P<RelaxTime>[^\s]+) seconds"
-        ),
-        re.compile(
-            "Explored (?P<NodeCount>\d+) nodes \((?P<IterCount>\d+) simplex iterations\) in (?P<Runtime>[^\s]+) seconds"
-        ),
-        re.compile(
-            "Barrier performed (?P<BarIterCount>\d+) iterations in [^\s]+ seconds"
-        ),
-        re.compile(
-            "Barrier solved model in (?P<BarIterCount>\d+) iterations and [^\s]+ seconds"
-        ),
-        re.compile(
-            "Push phase complete: Pinf (?P<PushPhasePInf>[^,]+), Dinf (?P<PushPhaseDInf>[^,]+)\s+(?P<PushPhaseEndTime>\d+)s"
-        ),
-        re.compile("Read (MPS|LP) format model from file (?P<ModelFilePath>.*)$"),
-        re.compile(
-            "(?!Presolved)(?P<Model>.*): \d+ (R|r)ows, \d+ (C|c)olumns, \d+ (N|n)on(Z|z)ero(e?)s"
-        ),
-        re.compile("Presolved model has (?P<PresolvedNumSOS>\d+) SOS constraint(s)\n"),
-        re.compile(
-            "Presolved model has (?P<PresolvedNumQNZs>\d+) quadratic objective terms"
-        ),
-        re.compile("Gurobi Optimizer version (?P<Version>\d{1,2}\.[^\s]+)"),
-    ]
-
-    # Patterns that come BEFORE any final termination message if found
-    # This list is used to discard earlier termination messages (e.g. from MIP root node)
-    pre_termination = [
-        re.compile(re.escape(" Expl Unexpl |  Obj  Depth IntInf |")),
-        re.compile("Root barrier log"),
-        re.compile("Root simplex log"),
-        re.compile("Root relaxation:"),
-        re.compile("Crossover log"),
-        re.compile("Total elapsed time = "),
-    ]
-
-    # Presolve information (only last occurence is taken)
-    presolve = re.compile(
-        "Presolved: (?P<PresolvedNumConstrs>\d+) (R|r)ows, (?P<PresolvedNumVars>\d+) (C|c)olumns, (?P<PresolvedNumNZs>\d+) (N|n)on(Z|z)ero(e?)s"
-    )
-    presolve_complete = re.compile("Presolve: All rows and columns removed")
-
-
-def _regex_group_to_field_name(groupname):
-    if groupname.startswith("_"):
-        return groupname[1:] + " (Parameter)"
-    else:
-        return groupname
-
-
-def _regex_matches(lines, regex_list, reverse=True, matchLimit=float("inf")):
-    """(Reverse) iterate over a list of log lines, call match() for a list of regex and return first successful match as (lineNumber, groupDict) up to matchLimit lines."""
-
-    # Check parameters
-    if not isinstance(lines, list):
-        raise TypeError()
-    if isinstance(regex_list, type(re.compile("test"))):
-        regex_list = [regex_list]
-    if not isinstance(regex_list, list):
-        raise TypeError()
-    if any(not isinstance(regex, type(re.compile("test"))) for regex in regex_list):
-        raise TypeError()
-    if matchLimit < 1:
-        raise ValueError()
-
-    line_order = range(len(lines) - 1, -1, -1) if reverse else range(0, len(lines))
-
-    matches = []
-    for line in line_order:
-        for regex in regex_list:
-            result = regex.match(lines[line])
-            if result:
-                groupdict = {
-                    _regex_group_to_field_name(key): value
-                    for key, value in result.groupdict().items()
-                }
-                matches.append((line, groupdict))
-                if len(matches) >= matchLimit:
-                    return matches
-                continue
-
-    return matches
-
-
-def _regex_first_match(lines, regex_list, reverse=True):
-    """(Reverse) iterate over a list of log lines, call match() for a list of regex and return first successful match as (lineNumber, groupDict)."""
-
-    # Check parameters
-    if not isinstance(lines, list):
-        raise TypeError()
-    if isinstance(regex_list, type(re.compile("test"))):
-        regex_list = [regex_list]
-    if not isinstance(regex_list, list):
-        raise TypeError()
-    if any(not isinstance(regex, type(re.compile("test"))) for regex in regex_list):
-        raise TypeError()
-
-    line_order = range(len(lines) - 1, -1, -1) if reverse else range(0, len(lines))
-
-    for line in line_order:
-        for regex in regex_list:
-            result = regex.match(lines[line])
-            if result:
-                groupdict = {
-                    _regex_group_to_field_name(key): value
-                    for key, value in result.groupdict().items()
-                }
-                return (line, groupdict)
-
-    return (None, {})
-
-
-def get_log_status(loglines):
-    """Check if list of log lines contains a complete log"""
-
-    if not isinstance(loglines, list):
-        raise TypeError("Wrong log format")
-
-    # Check first message
-    first_line, result = _regex_first_match(loglines, logpattern.headers, reverse=True)
-    if not result:
-        return logstatus.INCOMPLETE
-    loglines = loglines[first_line:]
-
-    # Filter log lines that are not final termination lines
-    loglines_before_termination = loglines
-    pre_termination_line, result = _regex_first_match(
-        loglines, logpattern.pre_termination, reverse=True
-    )
-    if result is not None:
-        loglines_before_termination = loglines_before_termination[pre_termination_line:]
-
-    _, result = _regex_first_match(
-        loglines_before_termination, logpattern.termination_message, reverse=True
-    )
-    if not result:
-        return logstatus.INCOMPLETE
-    else:
-        if result.get("ErrorMessage", None) is not None:
-            return logstatus.FAILED
-
-    # Check termination status
-    _, result = _regex_first_match(
-        loglines, logpattern.termination_status, reverse=True
-    )
-    if not result:
-        return logstatus.INCOMPLETE
-
-    # Simple pattern
-    for regex in logpattern.various:
-        _, result = _regex_first_match(loglines, regex, reverse=True)
-
-    return logstatus.FINISHED
-
-
-def _get_last_nonempty_line(loglines, start_line):
-    """Check loglines starting at index start_line and return the number of the last line that is not empty"""
-
-    last_line = len(loglines) - 1
-    for i in range(start_line, len(loglines)):
-        if len(loglines[i].strip()) == 0:
-            last_line = i - 1
-            break
-
-    return last_line
-
-
-def _get_last_tree_line(loglines, start_line):
-    """Check loglines starting at index start_line and return the number of the 'Explored ... nodes' line"""
-
-    last_line = len(loglines)
-    explored = re.compile(r"Explored \d+ nodes")
-    for i in range(start_line, len(loglines)):
-        if explored.match(loglines[i]):
-            break
-
-    return last_line - 1
-
-
-def _get_typed_values(values):
-
-    typed_values = {}
-
-    int_regex = re.compile("[-+]?\d+$")
-    float_regex = re.compile("[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?$")
-    percentage_regex = re.compile("[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?%$")
-
-    for key, value in values.items():
-        if not isinstance(value, str):
-            continue
-        if int_regex.match(value):
-            typed_values[key] = int(value)
-        elif float_regex.match(value):
-            typed_values[key] = float(value)
-        elif percentage_regex.match(value):
-            typed_values[key] = float(value[:-1]) / 100
-
-    return typed_values
-
-
-# Used to match and parse floating point numbers of any format
-float_pattern = "[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?"
-
-# Regular expressions for different log line types
-tree_search_full_log_line_regex = re.compile(
-    r"\s\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Obj>{0})\s+(?P<Depth>\d+)\s+(?P<IntInf>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>(-|{0}%))\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
-        float_pattern
-    )
-)
-tree_search_nodepruned_line_regex = re.compile(
-    r"\s\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Obj>(cutoff|infeasible|postponed))\s+(?P<Depth>\d+)\s+(?P<Incumbent>(-|{0}))\s+(?P<BestBd>{0})\s+(?P<Gap>(-|{0}%))\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
-        float_pattern
-    )
-)
-tree_search_new_solution_heuristic_log_line_regex = re.compile(
-    r"(?P<NewSolution>H)\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>{0}%)\s+(?P<ItPerNode>(-|{0}))\s+(?P<Time>\d+)s".format(
-        float_pattern
-    )
-)
-tree_search_new_solution_branching_log_line_regex = re.compile(
-    r"(?P<NewSolution>\*)\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Depth>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>{0}%)\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
-        float_pattern
-    )
-)
-tree_search_status_line_regex = re.compile(
-    r"\s\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Obj>-)\s+(?P<Depth>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>(-|{0}%))\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
-        float_pattern
-    )
-)
-
-def populate_tree_search_log(tree_search_log_lines):
-
-    tree_search_log = []
-    ignored_lines = 0
-    lastheur = None
-
-    for tree_search_log_line in tree_search_log_lines:
-
-        result = tree_search_full_log_line_regex.match(
-            tree_search_log_line.rstrip()
-        )
-
-        line, result = _regex_first_match(
-            [tree_search_log_line.rstrip()],
-            [
-                tree_search_full_log_line_regex,
-                tree_search_nodepruned_line_regex,
-                tree_search_status_line_regex,
-                tree_search_new_solution_branching_log_line_regex,
-                tree_search_new_solution_heuristic_log_line_regex,
-            ],
-        )
-
-        if line is not None:
-            result.update(_get_typed_values(result))
-            if lastheur and result.get("NewSolution") == "H":
-                print(lastheur)
-                result["NewSolution"] = lastheur
-            tree_search_log.append(result)
-        else:
-            ignored_lines += 1
-
-    return tree_search_log, ignored_lines
-
-
-def get_log_info(values, loglines, verbose=False, populate_tree_search_log=populate_tree_search_log):
-
-    if not isinstance(loglines, list):
-        raise TypeError("Wrong log format")
-
-    # Default values
-    values.update({"ObjVal": "-", "ObjBound": "-", "MIPGap": "-"})
-
-    # Step 1: Find last header line
-    first_line, result = _regex_first_match(loglines, logpattern.headers, reverse=True)
-    if not result:
-        print("Error: Could not find initial log message in logfile")
-        return None
-    else:
-        # get all header information
-        for _,result in _regex_matches(loglines[:first_line+1], logpattern.headers):
-            values.update(result)
-
-    # Discard previous lines
-    loglines = loglines[first_line:]
-
-    # Step 2: Find valid (final) termination message
-    loglines_before_termination = loglines
-    pre_termination_line, result = _regex_first_match(
-        loglines, logpattern.pre_termination, reverse=True
-    )
-    if result is not None:
-        loglines_before_termination = loglines_before_termination[pre_termination_line:]
-
-    line, result = _regex_first_match(
-        loglines_before_termination, logpattern.termination_message, reverse=True
-    )
-
-    if not result:
-        print("Error: Could not find termination log message. Logfile incomplete?")
-        return None
-    else:
-        values.update(result)
-
-    # Step 3: Check status on exit
-    line, result = _regex_first_match(
-        loglines, logpattern.termination_status, reverse=True
-    )
-
-    if not result:
-        print("Error: Could not determine termination status. Logfile incomplete?")
-        return None
-    else:
-        # Status Status and additional information (a bit hacky: identify status by CAPITAL_AND_UNDERSCORE_ONLY group name)
-        for key, value in result.items():
-            if not re.match("[A-Z_]+$", key):
-                values[key] = value
-            else:
-                values["Status"] = key
-
-    # Presolve information (reverse order is important for lookup!)
-    line, result = _regex_first_match(loglines, logpattern.presolve, reverse=True)
-    if result:
-        values.update(result)
-    else:
-        line, result = _regex_first_match(
-            loglines, logpattern.presolve_complete, reverse=False
-        )
-        if line is not None:
-            values["PresolvedNumConstrs"] = 0
-            values["PresolvedNumNZs"] = 0
-            values["PresolvedNumVars"] = 0
-
-    # Coefficient statistics
-    line, result = _regex_first_match(
-        loglines, re.compile("Coefficient statistics:"), reverse=False
-    )
-    if line:
-
-        # Check patterns on next 5 lines
-        coefficient_statistic_lines = loglines[line + 1 : line + 6]
-        for regex in logpattern.coefficients:
-            line, result = _regex_first_match(
-                coefficient_statistic_lines, re.compile(regex), reverse=False
-            )
-            values.update(result)
-
-    # Simple pattern
-    for regex in logpattern.various:
-        line, result = _regex_first_match(loglines, regex, reverse=True)
-        if result:
-
-            # Update values, but do not overwrite existing values
-            result.update(values)
-            values = result
-
-    # Result files
-    result_files = []
-    for line, result in _regex_matches(
-        loglines, re.compile("Wrote result file '(?P<ResultFile>.*)'$"), reverse=False
-    ):
-        result_files.append(result["ResultFile"])
-
-    if len(result_files) > 0:
-        values["ResultFiles"] = result_files
-
-    # Cut counts
-    cut_count_first_line, result = _regex_first_match(
-        loglines, re.compile("Cutting planes:"), reverse=False
-    )
-    if cut_count_first_line:
-        cut_count_last_line = _get_last_nonempty_line(
-            loglines, cut_count_first_line + 1
-        )
-        for line, result in _regex_matches(
-            loglines[cut_count_first_line + 1 : cut_count_last_line + 1],
-            re.compile("  (?P<Name>[\w\- ]+): (?P<Count>\d+)"),
-            reverse=False,
-        ):
-            if result:
-                values["Cuts: " + result["Name"].strip()] = int(result["Count"])
-
-    # NoRel log
-    norel_log_start = re.compile("Starting NoRel heuristic")
-    norel_first_line, result = _regex_first_match(
-        loglines, [norel_log_start], reverse=False
-    )
-    if norel_first_line and norel_first_line < len(loglines) - 1:
-        norel_last_line = _get_last_nonempty_line(loglines, norel_first_line + 1)
-        norel_primal_regex = re.compile(
-            "Found heuristic solution:\sobjective\s(?P<Incumbent>[^\s]+)"
-        )
-        norel_elapsed_time = re.compile(
-            "Elapsed time for NoRel heuristic:\s(?P<Time>\d+)s"
-        )
-        norel_elapsed_bound = re.compile(
-            "Elapsed time for NoRel heuristic:\s(?P<Time>\d+)s\s\(best\sbound\s(?P<BestBd>[^\s]+)\)"
-        )
-        norel_log = []
-        norel_incumbent = {}
-        for norel_log_line in loglines[
-            norel_first_line + 1 : norel_last_line + 1
-        ]:
-            # NoRel shows the solutions and timings/bounds on different lines, so
-            # when we see a timing line, we store the most recent incumbent there,
-            # instead of recording the primal when the log line is found.
-            result = norel_primal_regex.match(norel_log_line)
-            if result:
-                norel_incumbent = result.groupdict()
-            result = norel_elapsed_bound.match(norel_log_line) or norel_elapsed_time.match(norel_log_line)
-            if result:
-                tmp = result.groupdict()
-                tmp.update(norel_incumbent)
-                norel_log.append(tmp)
-        if len(norel_log) > 0:
-            values["NoRelLog"] = norel_log
-
-    # Simplex Log (can be regular LP, root node or crossover)
-    simplex_log_start = re.compile(
-        "Iteration(\s+)Objective(\s+)Primal Inf.(\s+)Dual Inf.(\s+)Time"
-    )
-    simplex_first_line, result = _regex_first_match(
-        loglines, [simplex_log_start], reverse=False
-    )
-    if simplex_first_line and simplex_first_line < len(loglines) - 1:
-
-        # Slice out simplex log lines
-        simplex_last_line = _get_last_nonempty_line(loglines, simplex_first_line + 1)
-        simplex_log_line_regex = re.compile(
-            "\s*(?P<Iteration>\d+)\s+(?P<Objective>[^\s]+)\s+(?P<PInf>[^\s]+)\s+(?P<DInf>[^\s]+)\s+(?P<Time>\d+)s"
-        )
-        simplex_log = []
-        for simplex_log_line in loglines[
-            simplex_first_line + 1 : simplex_last_line + 1
-        ]:
-            result = simplex_log_line_regex.match(simplex_log_line)
-            if result:
-                simplex_log.append(result.groupdict())
-
-        if len(simplex_log) > 0:
-            values["SimplexLog"] = simplex_log
-
-    # Barrier log (without barrier statistics)
-    barrier_log_start = re.compile(
-        "Iter(\s+)Primal(\s+)Dual(\s+)Primal(\s+)Dual(\s+)Compl(\s+)Time"
-    )
-    barrier_first_line, result = _regex_first_match(
-        loglines, [barrier_log_start], reverse=False
-    )
-    if barrier_first_line and barrier_first_line < len(loglines) - 1:
-
-        # Slice out barrier log lines
-        barrier_last_line = _get_last_nonempty_line(loglines, barrier_first_line + 1)
-        barrier_log_line_regex = re.compile(
-            "\s*(?P<Iteration>\d+)(?P<Indicator>\s|\*)\s+(?P<PObj>[^\s]+)\s+(?P<DObj>[^\s]+)\s+(?P<PRes>[^\s]+)\s+(?P<DRes>[^\s]+)\s+(?P<Compl>[^\s]+)\s+(?P<Time>\d+)s"
-        )
-        barrier_log = []
-        for barrier_log_line in loglines[
-            barrier_first_line + 1 : barrier_last_line + 1
-        ]:
-            result = barrier_log_line_regex.match(barrier_log_line)
-            if result:
-                barrier_log.append(result.groupdict())
-
-        if len(barrier_log) > 0:
-            values["BarrierLog"] = barrier_log
-
-    # Tree Search Log
-    tree_search_log_start = re.compile(r" Expl Unexpl(.*)It/Node Time$")
-
-    tree_search_first_line, result = _regex_first_match(
-        loglines, [tree_search_log_start], reverse=False
-    )
-    if tree_search_first_line and tree_search_first_line < len(loglines) - 2:
-
-        # Slice out tree search log lines
-        tree_search_last_line = _get_last_tree_line(
-            loglines, tree_search_first_line + 2
-        )
-
-        tree_search_log, ignored_lines = populate_tree_search_log(
-            loglines[tree_search_first_line + 2 : tree_search_last_line + 1],
-        )
-
-        if len(tree_search_log) > 0:
-            values["TreeSearchLog"] = tree_search_log
-            if ignored_lines > 0 and verbose:
-                print("Info: Ignored", ignored_lines, "tree search lines")
-
-    # Warnings
-    # e.g.
-    # Warning: Model contains large matrix coefficient range
-    # Warning: switch to quad precision
-    # Warning: 1 variables dropped from basis
-    # Warning: unscaled primal violation = 1 and residual = 2.38419e-07
-    # Warning: unscaled dual violation = 0.000662516 and residual = 0.000662516
-    # Warning: max bound violation (1.5772e-06) exceeds tolerance
-    # Warning: max constraint violation (2.4714e-03) exceeds tolerance
-
-    all_warnings = [line.strip() for line in loglines if line.startswith("Warning:")]
-    if len(all_warnings) > 0:
-        values["Warnings"] = all_warnings
-        values["NumWarnings"] = len(all_warnings)
-
-    # Parameters
-    parameters = {}
-    for line, result in _regex_matches(loglines, logpattern.parameters, reverse=True):
-
-        # Chronological order, will override previous values
-        parameters[result["Name"]] = result["Value"]
-
-    # Add parameters but add suffix if a value is already present
-    for key, value in parameters.items():
-        values["%s (Parameter)" % key] = value
-
-    # typed values (int, double) if possible
-    values.update(_get_typed_values(values))
-
-    # Derive model type
-    model_type = ""
-
-    # Int/Bin variables?
-    mip_var_types = (
-        values.get("PresolvedNumBinVars", 0)
-        + values.get("PresolvedNumIntVars", 0)
-        + values.get("PresolvedNumSemiIntVars", 0)
-        + values.get("PresolvedNumSemiContVars", 0)
-    )
-
-    if mip_var_types > 0 or "RelaxIterCount" in values or "NodeCount" in values:
-        model_type += "MI"
-
-    # Something quadratic?
-    if values.get("NumQNZs", 0) > 0 or values.get("NumQConstrs", 0) > 0:
-        model_type += "Q"
-
-    # Quadratic constraints?
-    if values.get("NumQConstrs", 0) > 0:
-        model_type += "C"
-
-    if model_type == "":
-        model_type = "LP"
-    else:
-        model_type += "P"
-
-    values["ModelType"] = model_type
-
-    return values
-
-
-def _strip_modelname(logname, modelname):
-    """small helper function to strip the model name from the log name"""
-    logname = os.path.basename(logname)
-    startmodel = logname.find(modelname)
-    if startmodel == -1:
-        return os.path.splitext(logname)[0]
-    else:
-        return logname[: startmodel - 1]
-
-
-def _copy_keys(source, target):
-    """copy some more info from one DataFrame to another to better distinguish runs/logs"""
-    # tl_ = tl_[tl_.columns].apply(pd.to_numeric, errors='coerce')
-    keys = ["Log", "LogFilePath", "Seed", "Model", "ModelFile", "Version"]
-    for key in keys:
-        if source.get(key) is not None:
-            target[key] = source[key].iloc[0]
-
-
-def get_dataframe(
-    logfiles,
-    timelines=False,
-    verbose=False,
-    merged_logs=False,
-    prettyparams=False,
-    get_log_info=get_log_info,
-):
-    """Create a dataframe with all stats of the logs
-
-    - timelines=True: also create dictionary of timelines of all logs
-    - verbose=True: print progress info
-    - merged_logs: if False, only the last log in the file is parsed
-    """
-
-    glob_logfiles = []
-
-    # Expand wildcards, skip non-existing elements
-    for logfile in logfiles:
-        logfile_expanded = glob.glob(logfile)
-        if not logfile_expanded:
-            print("Info: Log file not found: '%s'" % logfile)
-        else:
-            glob_logfiles.extend(logfile_expanded)
-
-    log_infos = []
-    for logfile in glob_logfiles:
-        if verbose:
-            print(f"processing {logfile}...")
-        with open(logfile) as f:
-            loglines = f.readlines()
-            if merged_logs:
-                header = [
-                    ind
-                    for ind, l in enumerate(loglines)
-                    if re.search(logpattern.headers[0], l)
-                ]
-                header.append(len(loglines))
-            else:
-                header = [0, len(loglines)]
-            for i,h in enumerate(range(len(header) - 1)):
-                log_info = get_log_info(dict(), loglines[header[h] : header[h + 1]], verbose)
-                if log_info is None:
-                    print(f"error processing {logfile}")
-                    continue
-                # add some more information to better distinguish the individual runs/logs
-                log_info["LogFilePath"] = os.path.abspath(logfile)
-                log_info["Log"] = os.path.splitext(os.path.basename(logfile))[0]
-
-                # add counter if using merged_logs:
-                log_info["LogFilePath"] += f"({i+1})"
-
-                if log_info.get("ModelFilePath"):
-                    log_info["ModelFile"] = os.path.basename(
-                        log_info["ModelFilePath"]
-                    ).split(os.path.extsep)[0]
-                    log_info["Log"] = _strip_modelname(
-                        log_info["LogFilePath"], log_info["ModelFile"]
-                    )
-                else:
-                    log_info["Log"] = os.path.splitext(
-                        os.path.basename(log_info["LogFilePath"])
-                    )[0]
-
-                log_infos.append(log_info)
-
-    allresults = []
-    for log_info in log_infos:
-        items = {
-            key: value
-            for (key, value) in log_info.items()
-            if not isinstance(value, list) and not isinstance(value, dict)
-        }
-        allresults.append(items)
-
-    summary = pd.DataFrame(allresults)
-
-    if "Seed (Parameter)" in summary.keys():
-        summary["Seed"] = summary["Seed (Parameter)"].fillna(0).astype(int).astype(str)
-        summary = summary.drop(labels="Seed (Parameter)", axis=1)
-
-    summary = fill_default_parameters(summary.replace("-", nan))
-    if prettyparams:
-        summary = add_categorical_descriptions(summary)
-
-    if timelines:
-
-        # collect norel log
-        norel = pd.DataFrame()
-        for log_info in log_infos:
-            log = log_info.get("LogFilePath")
-            final = summary[summary["LogFilePath"] == log]
-            nrlines = log_info.get("NoRelLog")
-            if nrlines is not None:
-                norel_ = pd.DataFrame(nrlines).apply(
-                    pd.to_numeric, errors="coerce"
-                )
-                _copy_keys(final, norel_)
-                norel = norel.append(norel_, ignore_index=True)
-
-        # collect root LP log
-        rootlp = pd.DataFrame()
-        for log_info in log_infos:
-            log = log_info.get("LogFilePath")
-            final = summary[summary["LogFilePath"] == log]
-            slines = log_info.get("SimplexLog")
-            blines = log_info.get("BarrierLog")
-            simplex_ = pd.DataFrame(slines)
-            barrier_ = pd.DataFrame(blines)
-            crossover = False
-            # filter out empty dictionaries and convert all values to numerics
-            if barrier_.get("Iteration") is not None:
-                barrier_ = barrier_[barrier_.columns].apply(
-                    pd.to_numeric, errors="coerce"
-                )
-                _copy_keys(final, barrier_)
-                barrier_["Type"] = "barrier"
-                rootlp = rootlp.append(barrier_, ignore_index=True)
-                crossover = True
-            # filter out empty dictionaries and convert all values to numerics
-            if simplex_.get("Iteration") is not None:
-                simplex_ = simplex_[simplex_.columns].apply(
-                    pd.to_numeric, errors="coerce"
-                )
-                _copy_keys(final, simplex_)
-                simplex_["Type"] = "crossover" if crossover else "simplex"
-                rootlp = rootlp.append(simplex_, ignore_index=True)
-
-        # collect tree search log
-        tl = pd.DataFrame()
-        for log_info in log_infos:
-            lines = log_info.get("TreeSearchLog")
-            tl_ = pd.DataFrame(lines)
-            # filter out empty dictionaries and convert all values to numerics
-            if tl_.get("Time") is not None:
-                log = log_info.get("LogFilePath")
-                final = summary[summary["LogFilePath"] == log]
-                # append one last row containing the final data
-                tl_ = tl_.append(
-                    pd.DataFrame(
-                        {
-                            "CurrentNode": final["NodeCount"],
-                            "Incumbent": final["ObjVal"],
-                            "BestBd": final["ObjBound"],
-                            "Gap": final["MIPGap"],
-                            "Time": final["Runtime"],
-                        }
-                    )
-                )
-                non_numeric_cols = set(tl_.columns).intersection({"NewSolution"})
-                numeric_cols = set(tl_.columns).difference(non_numeric_cols)
-                tl_ = pd.concat(
-                    [
-                        tl_[numeric_cols].apply(pd.to_numeric, errors="coerce"),
-                        tl_[non_numeric_cols],
-                    ],
-                    axis="columns",
-                )
-                _copy_keys(final, tl_)
-                # NB: losing all the heuristic info here!!
-
-                tl = tl.append(tl_, ignore_index=True)
-
-        return summary, dict(nodelog=tl, rootlp=rootlp, norel=norel)
-    else:
-        return summary
-
-
-def plot(df: pd.DataFrame, points="all", barmode="group", **kwargs):
-    """plot different chart types to compare performance and see performance variability across random seeds
-
-    uses Plotly express; all available keyword arguments can be passed through to px.bar(), px.scatter(), etc.
-    """
-
-    options = list(df.columns) + [None]
-
-    if "Incumbent" in options:
-        # read custom axis data or use common defaults for summary DataFrame
-        x_default = kwargs.pop("x", "Time")
-        y_default = kwargs.pop("y", "Incumbent")
-        color_default = kwargs.pop("color", "Log")
-    else:
-        # read custom axis data or use common defaults for summary DataFrame
-        x_default = kwargs.pop("x", "Log")
-        y_default = kwargs.pop("y", "Runtime")
-        color_default = kwargs.pop("color", "Seed")
-
-    # set type to None to disable plotting until a type is selected
-    type_default = kwargs.pop("type", None)
-
-    # pop values for remaining widgets to avoid double inputs
-    symbol_default = kwargs.pop("symbol", None)
-    log_x_default = kwargs.pop("log_x", False)
-    log_y_default = kwargs.pop("log_y", False)
-
-    # check wether selected keys are available in DataFrame
-    if x_default not in options:
-        x_default = None
-    if y_default not in options:
-        y_default = None
-    if color_default not in options:
-        color_default = None
-
-    switches = dict(
-        x=widgets.Dropdown(options=options, value=x_default),
-        y=widgets.Dropdown(options=options, value=y_default),
-        color=widgets.Dropdown(options=options, value=color_default),
-        type=widgets.Dropdown(
-            options=["box", "bar", "scatter", "line"], value=type_default
-        ),
-        symbol=widgets.Dropdown(options=options, value=symbol_default),
-        log_x=widgets.Checkbox(value=log_x_default),
-        log_y=widgets.Checkbox(value=log_y_default),
-    )
-
-    @interact(**switches)
-    def _(x, y, color, type, symbol, log_x, log_y):
-        if type == "box":
-            return px.box(
-                df,
-                x=x,
-                y=y,
-                color=color,
-                points=points,
-                log_x=log_x,
-                log_y=log_y,
-                **kwargs,
-            )
-        elif type == "bar":
-            return px.bar(
-                df,
-                x=x,
-                y=y,
-                color=color,
-                barmode=barmode,
-                log_x=log_x,
-                log_y=log_y,
-                **kwargs,
-            )
-        elif type == "scatter":
-            return px.scatter(
-                df,
-                x=x,
-                y=y,
-                color=color,
-                symbol=symbol,
-                log_x=log_x,
-                log_y=log_y,
-                **kwargs,
-            )
-        elif type == "line":
-            return px.line(
-                df,
-                x=x,
-                y=y,
-                color=color,
-                symbol=symbol,
-                log_x=log_x,
-                log_y=log_y,
-                **kwargs,
-            )
diff --git a/src/grblogtools/helpers.py b/src/grblogtools/helpers.py
index 33477f1..4d853d1 100644
--- a/src/grblogtools/helpers.py
+++ b/src/grblogtools/helpers.py
@@ -1,22 +1,10 @@
-import json
-import pathlib
 import re
-from functools import lru_cache
 from functools import partial
+from pathlib import Path
 
+from grblogtools.parameters import load_defaults, load_descriptions
 
 re_parameter_column = re.compile(r"(.*) \(Parameter\)")
-defaults_dir = pathlib.Path(__file__).parent.joinpath("defaults")
-
-
-@lru_cache()
-def load_defaults(version):
-    version_file = defaults_dir.joinpath(f"{version}.json")
-    if not version_file.exists():
-        # Fall back to 950 defaults.
-        version_file = defaults_dir.joinpath("950.json")
-    with version_file.open() as infile:
-        return json.load(infile)
 
 
 def fill_for_version(group, parameter_columns):
@@ -31,10 +19,7 @@ def fill_for_version(group, parameter_columns):
 
 
 def fill_default_parameters(summary):
-    """If we leave defaults for a parameter .assign(
-            **{column: lambda df: dfas NaN, plotly seems to ignore these
-    records in the plot. So we should fill these values with the actual default.
-    """
+    """Fill NaN parameter values with the actual default value."""
     parameter_columns = [
         column
         for column, series in summary.items()
@@ -46,46 +31,48 @@ def fill_default_parameters(summary):
     )
 
 
-PARAMETER_DESCRIPTIONS = {
-    "Method (Parameter)": {
-        -1: "-1: Default",
-        0: "0: Primal Simplex",
-        1: "1: Dual Simplex",
-        2: "2: Barrier",
-        3: "3: Nondeterministic Concurrent",
-        4: "4: Deterministic Concurrent",
-        5: "5: Deterministic Concurrent Simplex",
-    },
-    "Presolve (Parameter)": {
-        -1: "-1: Automatic",
-        0: "0: Off",
-        1: "1: Conservative",
-        2: "2: Aggressive",
-    },
-    "Cuts (Parameter)": {
-        -1: "-1: Automatic",
-        0: "0: Off",
-        1: "1: Moderate",
-        2: "2: Aggressive",
-        3: "3: Very aggressive",
-    },
-    "MIPFocus (Parameter)": {
-        0: "0: Balanced",
-        1: "1: Feasibility",
-        2: "2: Optimality",
-        3: "3: Bound",
-    },
-}
+def fill_for_version_nosuffix(group):
+    parameter_defaults = load_defaults(
+        version=group["Version"].iloc[0].replace(".", "")
+    )
+    for parameter in group.columns:
+        default = parameter_defaults.get(parameter)
+        if default is not None:
+            group[parameter] = group[parameter].fillna(default).astype(type(default))
+    return group
+
+
+def fill_default_parameters_nosuffix(parameters):
+    """Fill defaults for Version and parameter cols with no (Parameter) suffix."""
+    return parameters.groupby("Version").apply(fill_for_version_nosuffix)
 
 
 def add_categorical_descriptions(summary):
-    """Replace some columns with categorical descriptions if available in the
-    PARAMETER_DESCRIPTIONS dictionary. Modifies :summary in place."""
-    parameter_columns = [
-        column for column in summary.columns if column in PARAMETER_DESCRIPTIONS
-    ]
+    """Replace some columns with categorical descriptions if available.
+
+    It modifies the summary dict in place.
+    """
+    descriptions = load_descriptions()
+    parameter_columns = [column for column in summary.columns if column in descriptions]
     for column in parameter_columns:
-        summary[column] = (
-            summary[column].map(PARAMETER_DESCRIPTIONS[column]).astype("category")
-        )
+        summary[column] = summary[column].map(descriptions[column]).astype("category")
     return summary
+
+
+def strip_model_and_seed(row):
+    """Return the Log name.
+
+    If the log path contains the model name, return everything to the left.
+    Otherwise, just return the log stem.
+
+    i.e. with Model = 'glass4'
+        data/912-Cuts0-glass4-0.log -> 912-Cuts0
+        data/some-log.log -> some-log
+    """
+    if row["Model"] is None:
+        return None
+    log_stem = Path(row["LogFilePath"]).stem
+    run, mid, _ = log_stem.partition(row["Model"])
+    if mid and run:
+        return run.rstrip("-")
+    return log_stem
diff --git a/src/grblogtools/parameters/__init__.py b/src/grblogtools/parameters/__init__.py
new file mode 100644
index 0000000..bfc3361
--- /dev/null
+++ b/src/grblogtools/parameters/__init__.py
@@ -0,0 +1,2 @@
+from grblogtools.parameters.defaults import load_defaults
+from grblogtools.parameters.pretty import load_descriptions
diff --git a/src/grblogtools/defaults/800.json b/src/grblogtools/parameters/data/800.json
similarity index 99%
rename from src/grblogtools/defaults/800.json
rename to src/grblogtools/parameters/data/800.json
index b76e8f0..ab5c661 100644
--- a/src/grblogtools/defaults/800.json
+++ b/src/grblogtools/parameters/data/800.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/801.json b/src/grblogtools/parameters/data/801.json
similarity index 99%
rename from src/grblogtools/defaults/801.json
rename to src/grblogtools/parameters/data/801.json
index b76e8f0..ab5c661 100644
--- a/src/grblogtools/defaults/801.json
+++ b/src/grblogtools/parameters/data/801.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/810.json b/src/grblogtools/parameters/data/810.json
similarity index 99%
rename from src/grblogtools/defaults/810.json
rename to src/grblogtools/parameters/data/810.json
index b76e8f0..ab5c661 100644
--- a/src/grblogtools/defaults/810.json
+++ b/src/grblogtools/parameters/data/810.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/811.json b/src/grblogtools/parameters/data/811.json
similarity index 99%
rename from src/grblogtools/defaults/811.json
rename to src/grblogtools/parameters/data/811.json
index b76e8f0..ab5c661 100644
--- a/src/grblogtools/defaults/811.json
+++ b/src/grblogtools/parameters/data/811.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/900.json b/src/grblogtools/parameters/data/900.json
similarity index 99%
rename from src/grblogtools/defaults/900.json
rename to src/grblogtools/parameters/data/900.json
index 0049a6b..8949ca4 100644
--- a/src/grblogtools/defaults/900.json
+++ b/src/grblogtools/parameters/data/900.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/901.json b/src/grblogtools/parameters/data/901.json
similarity index 99%
rename from src/grblogtools/defaults/901.json
rename to src/grblogtools/parameters/data/901.json
index 0049a6b..8949ca4 100644
--- a/src/grblogtools/defaults/901.json
+++ b/src/grblogtools/parameters/data/901.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/902.json b/src/grblogtools/parameters/data/902.json
similarity index 99%
rename from src/grblogtools/defaults/902.json
rename to src/grblogtools/parameters/data/902.json
index 0049a6b..8949ca4 100644
--- a/src/grblogtools/defaults/902.json
+++ b/src/grblogtools/parameters/data/902.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/903.json b/src/grblogtools/parameters/data/903.json
similarity index 99%
rename from src/grblogtools/defaults/903.json
rename to src/grblogtools/parameters/data/903.json
index 0049a6b..8949ca4 100644
--- a/src/grblogtools/defaults/903.json
+++ b/src/grblogtools/parameters/data/903.json
@@ -111,4 +111,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/910.json b/src/grblogtools/parameters/data/910.json
similarity index 99%
rename from src/grblogtools/defaults/910.json
rename to src/grblogtools/parameters/data/910.json
index 3f56b62..018ed5a 100644
--- a/src/grblogtools/defaults/910.json
+++ b/src/grblogtools/parameters/data/910.json
@@ -114,4 +114,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/911.json b/src/grblogtools/parameters/data/911.json
similarity index 99%
rename from src/grblogtools/defaults/911.json
rename to src/grblogtools/parameters/data/911.json
index 3f56b62..018ed5a 100644
--- a/src/grblogtools/defaults/911.json
+++ b/src/grblogtools/parameters/data/911.json
@@ -114,4 +114,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/912.json b/src/grblogtools/parameters/data/912.json
similarity index 99%
rename from src/grblogtools/defaults/912.json
rename to src/grblogtools/parameters/data/912.json
index 3f56b62..018ed5a 100644
--- a/src/grblogtools/defaults/912.json
+++ b/src/grblogtools/parameters/data/912.json
@@ -114,4 +114,4 @@
     "VarBranch": -1,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/defaults/950.json b/src/grblogtools/parameters/data/950.json
similarity index 99%
rename from src/grblogtools/defaults/950.json
rename to src/grblogtools/parameters/data/950.json
index 625a8b2..cfbe81f 100644
--- a/src/grblogtools/defaults/950.json
+++ b/src/grblogtools/parameters/data/950.json
@@ -121,4 +121,4 @@
     "WLSTokenDuration": 0,
     "ZeroHalfCuts": -1,
     "ZeroObjNodes": -1
-}
\ No newline at end of file
+}
diff --git a/src/grblogtools/parameters/data/__init__.py b/src/grblogtools/parameters/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/grblogtools/parameters/data/descriptions.json b/src/grblogtools/parameters/data/descriptions.json
new file mode 100644
index 0000000..07d4a3e
--- /dev/null
+++ b/src/grblogtools/parameters/data/descriptions.json
@@ -0,0 +1,90 @@
+{
+    "Cuts": [
+        {
+            "description": "Automatic",
+            "value": -1
+        },
+        {
+            "description": "Off",
+            "value": 0
+        },
+        {
+            "description": "Moderate",
+            "value": 1
+        },
+        {
+            "description": "Aggressive",
+            "value": 2
+        },
+        {
+            "description": "Very aggressive",
+            "value": 3
+        }
+    ],
+    "MIPFocus": [
+        {
+            "description": "Balanced",
+            "value": 0
+        },
+        {
+            "description": "Feasibility",
+            "value": 1
+        },
+        {
+            "description": "Optimality",
+            "value": 2
+        },
+        {
+            "description": "Bound",
+            "value": 3
+        }
+    ],
+    "Method": [
+        {
+            "description": "Default",
+            "value": -1
+        },
+        {
+            "description": "Primal Simplex",
+            "value": 0
+        },
+        {
+            "description": "Dual Simplex",
+            "value": 1
+        },
+        {
+            "description": "Barrier",
+            "value": 2
+        },
+        {
+            "description": "Nondeterministic Concurrent",
+            "value": 3
+        },
+        {
+            "description": "Deterministic Concurrent",
+            "value": 4
+        },
+        {
+            "description": "Deterministic Concurrent Simplex",
+            "value": 5
+        }
+    ],
+    "Presolve": [
+        {
+            "description": "Automatic",
+            "value": -1
+        },
+        {
+            "description": "Off",
+            "value": 0
+        },
+        {
+            "description": "Conservative",
+            "value": 1
+        },
+        {
+            "description": "Aggressive",
+            "value": 2
+        }
+    ]
+}
diff --git a/src/grblogtools/parameters/defaults.py b/src/grblogtools/parameters/defaults.py
new file mode 100644
index 0000000..4ef9924
--- /dev/null
+++ b/src/grblogtools/parameters/defaults.py
@@ -0,0 +1,15 @@
+import json
+from functools import lru_cache
+from pathlib import Path
+
+data_dir = Path(__file__).parent.joinpath("data")
+
+
+@lru_cache()
+def load_defaults(version):
+    version_file = data_dir.joinpath(f"{version}.json")
+    if not version_file.exists():
+        # Fall back to 950 defaults
+        version_file = data_dir.joinpath("950.json")
+    with version_file.open() as infile:
+        return json.load(infile)
diff --git a/src/grblogtools/parameters/pretty.py b/src/grblogtools/parameters/pretty.py
new file mode 100644
index 0000000..0553581
--- /dev/null
+++ b/src/grblogtools/parameters/pretty.py
@@ -0,0 +1,18 @@
+import json
+from functools import lru_cache
+from pathlib import Path
+
+data_dir = Path(__file__).parent.joinpath("data")
+
+
+@lru_cache()
+def load_descriptions():
+    with data_dir.joinpath("descriptions.json").open() as infile:
+        descriptions = json.load(infile)
+    return {
+        f"{parameter} (Parameter)": {
+            entry["value"]: "{value}: {description}".format(**entry)
+            for entry in entries
+        }
+        for parameter, entries in descriptions.items()
+    }
diff --git a/src/grblogtools/parsers/__init__.py b/src/grblogtools/parsers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/grblogtools/parsers/barrier.py b/src/grblogtools/parsers/barrier.py
new file mode 100644
index 0000000..608b117
--- /dev/null
+++ b/src/grblogtools/parsers/barrier.py
@@ -0,0 +1,87 @@
+import re
+
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class BarrierParser:
+    # The pattern indicating the initialization of the parser
+    barrier_start_pattern = re.compile(
+        r"Iter(\s+)Primal(\s+)Dual(\s+)Primal(\s+)Dual(\s+)Compl(\s+)Time"
+    )
+
+    barrier_ordering_pattern = re.compile(r"Ordering time: (?P<OrderingTime>[\d\.]+)s")
+
+    # The pattern indicating the barrier progress
+    barrier_progress_pattern = re.compile(
+        r"\s*(?P<Iteration>\d+)(?P<Indicator>\s|\*)\s+(?P<PObj>[^\s]+)\s+(?P<DObj>[^\s]+)\s+(?P<PRes>[^\s]+)\s+(?P<DRes>[^\s]+)\s+(?P<Compl>[^\s]+)\s+(?P<Time>\d+)s"
+    )
+
+    # The pattern indicating the crossover
+    barrier_crossover_pattern = re.compile(
+        r"\s*Push phase complete: Pinf (?P<PushPhasePInf>[^\s]+), Dinf (?P<PushPhaseDInf>[^\s]+)\s+(?P<PushPhaseEndTime>\d+)s"
+    )
+
+    # The pattern indicating the termination of the barrier algorithm
+    barrier_termination_patterns = [
+        re.compile(
+            r"Barrier solved model in (?P<BarIterCount>[^\s]+) iterations and (?P<Runtime>[^\s]+) seconds"
+        ),
+        re.compile(
+            r"Barrier performed (?P<BarIterCount>\d+) iterations in (?P<Runtime>[^\s]+) seconds"
+        ),
+    ]
+
+    def __init__(self):
+        """Initialize the Barrier parser."""
+        self._summary = {}
+        self._progress = []
+        self._started = False
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary and progress data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        barrier_ordering_match = BarrierParser.barrier_ordering_pattern.match(line)
+        if barrier_ordering_match:
+            self._summary.update(typeconvert_groupdict(barrier_ordering_match))
+            return True
+
+        if not self._started:
+            match = BarrierParser.barrier_start_pattern.match(line)
+            if match:
+                self._started = True
+                return True
+            return False
+
+        progress_match = BarrierParser.barrier_progress_pattern.match(line)
+        if progress_match:
+            entry = {"Type": "barrier"}
+            entry.update(typeconvert_groupdict(progress_match))
+            self._progress.append(entry)
+            return True
+
+        for barrier_termination_pattern in BarrierParser.barrier_termination_patterns:
+            barrier_termination_match = barrier_termination_pattern.match(line)
+            if barrier_termination_match:
+                self._summary.update(typeconvert_groupdict(barrier_termination_match))
+                return True
+
+        crossover_match = BarrierParser.barrier_crossover_pattern.match(line)
+        if crossover_match:
+            self._summary.update(typeconvert_groupdict(crossover_match))
+            return True
+
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        return self._summary
+
+    def get_progress(self) -> list:
+        """Return the detailed progress in the barrier method."""
+        return self._progress
diff --git a/src/grblogtools/parsers/continuous.py b/src/grblogtools/parsers/continuous.py
new file mode 100644
index 0000000..e043a4b
--- /dev/null
+++ b/src/grblogtools/parsers/continuous.py
@@ -0,0 +1,99 @@
+import re
+
+from grblogtools.parsers.barrier import BarrierParser
+from grblogtools.parsers.simplex import SimplexParser
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class ContinuousParser:
+    # The pattern indicating the start or the termination of the barrier/simplex
+    # in case of solving a MIP. In some cases, the log might only include this one
+    # line
+    mip_relaxation_pattern = re.compile(
+        r"Root relaxation: objective (?P<RelaxObj>[^,]+), (?P<RelaxIterCount>\d+) iterations, (?P<RelaxTime>[^\s]+) seconds"
+    )
+
+    barrier_interruption_pattern = re.compile(
+        r"Barrier solve interrupted - model solved by another algorithm"
+    )
+
+    continuous_termination_patterns = [
+        re.compile(
+            r"(?P<SUBOPTIMAL>Sub-optimal termination)(?: - objective (?P<ObjVal>.*))$"
+        ),
+        re.compile(r"(?P<OPTIMAL>Optimal objective\s+(?P<ObjVal>.*))$"),
+    ]
+
+    def __init__(self):
+        """Initialize the Continuous parser."""
+        self._barrier_parser = BarrierParser()
+        self._simplex_parser = SimplexParser()
+
+        self._summary = {}
+
+        self._current_pattern = None
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary and progress data.
+
+        It defers to the simplex and the barrier parsers as needed.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        mip_relaxation_match = ContinuousParser.mip_relaxation_pattern.match(line)
+        if mip_relaxation_match:
+            self._current_pattern = "relaxation"
+            self._summary.update(typeconvert_groupdict(mip_relaxation_match))
+            return True
+
+        for pattern in ContinuousParser.continuous_termination_patterns:
+            match = pattern.match(line)
+            if match:
+                for key, value in typeconvert_groupdict(match).items():
+                    if key in ["OPTIMAL", "SUBOPTIMAL"]:
+                        self._summary.update({"Status": key})
+                    else:
+                        self._summary.update({key: value})
+                return True
+
+        if self._current_pattern is None:
+            if self._barrier_parser.parse(line):
+                self._current_pattern = "barrier"
+                return True
+            if self._simplex_parser.parse(line):
+                self._current_pattern = "simplex"
+                return True
+
+            return False
+
+        if self._current_pattern == "barrier":
+            matched = self._barrier_parser.parse(line)
+            # If the barrier gets interrupted during the concurrent or there are
+            # extra simplex iterations, switch to simplex
+            if not matched and (
+                ContinuousParser.barrier_interruption_pattern.match(line)
+                or self._simplex_parser.parse(line)
+            ):
+                self._current_pattern = "simplex"
+                return True
+            return matched
+
+        if self._current_pattern == "simplex":
+            match = self._simplex_parser.parse(line)
+            return match
+
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        self._summary.update(self._barrier_parser.get_summary())
+        self._summary.update(self._simplex_parser.get_summary())
+        return self._summary
+
+    def get_progress(self) -> list:
+        """Return the detailed progress in the continuous method."""
+        return self._barrier_parser.get_progress() + self._simplex_parser.get_progress()
diff --git a/src/grblogtools/parsers/header.py b/src/grblogtools/parsers/header.py
new file mode 100644
index 0000000..9b0418e
--- /dev/null
+++ b/src/grblogtools/parsers/header.py
@@ -0,0 +1,88 @@
+import re
+
+from grblogtools.parsers.util import convert_data_types, typeconvert_groupdict
+
+
+class HeaderParser:
+    header_start_patterns = [
+        re.compile(
+            r"Gurobi (?P<Version>\d{1,2}\.[^\s]+) \((?P<Platform>[^\)]+)\) logging started (?P<Time>.*)$"
+        ),
+        re.compile(r"Logging started (?P<Time>.*)$"),
+        re.compile(
+            r"Gurobi Compute Server Worker version (?P<Version>\d{1,2}\.[^\s]+) build (.*) \((?P<Platform>[^\)]+)\)$"
+        ),
+        re.compile(r"Compute Server job ID: (?P<JobID>.*)$"),
+        re.compile(r"Gurobi Optimizer version (?P<Version>\d{1,2}\.[^\s]+)"),
+        re.compile(
+            r"Gurobi Compute Server Worker version (?P<Version>\d{1,2}\.[^\s]+) build (.*) \((?P<Platform>[^\)]+)\)$"
+        ),
+        re.compile(r"Compute Server job ID: (?P<JobID>.*)$"),
+        re.compile(r"Gurobi Optimizer version (?P<Version>\d{1,2}\.[^\s]+)"),
+    ]
+
+    header_other_patterns = [
+        re.compile(r"Read (MPS|LP) format model from file (?P<ModelFilePath>.*)$"),
+        re.compile(r"Reading time = (?P<ReadingTime>[\d\.]+) seconds"),
+        re.compile(
+            r"Thread count: (?P<PhysicalCores>\d+) physical cores, (?P<LogicalProcessors>\d+) logical processors, using up to (?P<Threads>\d+) threads"
+        ),
+        re.compile(
+            r"(?P<ModelName>.*): (?P<Rows>\d+) rows, (?P<Columns>\d+) columns, (?P<Nonzeros>\d+) nonzeros"
+        ),
+    ]
+
+    # Special case for parameter changes
+    parameter_change_pattern = re.compile(
+        r"Set parameter (?P<ParamName>[^\s]+) to value (?P<ParamValue>.*)$"
+    )
+
+    def __init__(self):
+        """Initialize the Header parser.
+
+        Parameters are stored separately from the summary data as they are
+        handled differently in the final output.
+        """
+        self._summary = {}
+        self._parameters = {}
+        self._started = False
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        match = HeaderParser.parameter_change_pattern.match(line)
+        if match:
+            self._parameters[match.group("ParamName")] = convert_data_types(
+                match.group("ParamValue")
+            )
+            return True
+
+        for pattern in HeaderParser.header_start_patterns:
+            match = pattern.match(line)
+            if match:
+                self._started = True
+                self._summary.update(typeconvert_groupdict(match))
+                return True
+
+        if self._started:
+            for pattern in HeaderParser.header_other_patterns:
+                match = pattern.match(line)
+                if match:
+                    self._summary.update(typeconvert_groupdict(match))
+                    return True
+
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        return self._summary
+
+    def get_parameters(self) -> dict:
+        """Return all changed parameters detected in the header."""
+        return self._parameters
diff --git a/src/grblogtools/parsers/nodelog.py b/src/grblogtools/parsers/nodelog.py
new file mode 100644
index 0000000..8552a35
--- /dev/null
+++ b/src/grblogtools/parsers/nodelog.py
@@ -0,0 +1,124 @@
+import re
+
+from grblogtools.parsers.util import convert_data_types, typeconvert_groupdict
+
+float_pattern = r"[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?"
+
+
+class NodeLogParser:
+    tree_search_start = re.compile(r" Expl Unexpl(.*)It/Node Time$")
+
+    tree_search_final_stats = [
+        re.compile(
+            r"Explored (?P<NodeCount>\d+) nodes \((?P<IterCount>\d+) simplex iterations\) in (?P<Runtime>[^\s]+) seconds"
+        ),
+        re.compile(
+            r"Best objective (?P<ObjVal>[^,]+), best bound (?P<ObjBound>[^,]+), gap (?P<MIPGap>.*)$"
+        ),
+    ]
+
+    line_types = [
+        # tree_search_full_log_line_regex
+        re.compile(
+            r"\s\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Obj>{0})\s+(?P<Depth>\d+)\s+(?P<IntInf>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>(-|{0}%))\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
+                float_pattern
+            )
+        ),
+        # tree_search_nodepruned_line_regex
+        re.compile(
+            r"\s\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Pruned>(cutoff|infeasible|postponed))\s+(?P<Depth>\d+)\s+(?P<Incumbent>(-|{0}))\s+(?P<BestBd>{0})\s+(?P<Gap>(-|{0}%))\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
+                float_pattern
+            )
+        ),
+        # tree_search_new_solution_heuristic_log_line_regex
+        re.compile(
+            r"(?P<NewSolution>H)\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>{0}%)\s+(?P<ItPerNode>(-|{0}))\s+(?P<Time>\d+)s".format(
+                float_pattern
+            )
+        ),
+        # tree_search_new_solution_branching_log_line_regex
+        re.compile(
+            r"(?P<NewSolution>\*)\s*(?P<CurrentNode>\d+)\s+(?P<RemainingNodes>\d+)\s+(?P<Depth>\d+)\s+(?P<Incumbent>({0}|-))\s+(?P<BestBd>{0})\s+(?P<Gap>{0}%)\s+(?P<ItPerNode>({0}|-))\s+(?P<Time>\d+)s".format(
+                float_pattern
+            )
+        ),
+    ]
+    cut_report_start = re.compile(r"Cutting planes:")
+    cut_report_line = re.compile(r"  (?P<Name>[\w\- ]+): (?P<Count>\d+)")
+
+    def __init__(self):
+        """Initialize the NodeLog parser."""
+        self._summary = {}
+        self._cuts = {}
+        self._progress = []
+        self._in_cut_report = False
+        self._started = False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        summary = self._summary
+        summary.update({f"Cuts: {name}": count for name, count in self._cuts.items()})
+        return summary
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary and progress data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+
+        for regex in self.tree_search_final_stats:
+            match = regex.match(line)
+            if match:
+                entry = typeconvert_groupdict(match)
+                self._summary.update(entry)
+                return True
+
+        match = self.cut_report_start.match(line)
+        if match:
+            self._in_cut_report = True
+            return True
+
+        if self._in_cut_report:
+            match = self.cut_report_line.match(line)
+            if match:
+                self._cuts[match.group("Name")] = convert_data_types(
+                    match.group("Count")
+                )
+                return True
+
+        # Wait for the header before matching any log lines.
+        if not self._started:
+            match = self.tree_search_start.match(line)
+            if match:
+                self._started = True
+                return True
+            return False
+
+        # Match log lines.
+        for regex in self.line_types:
+            match = regex.match(line)
+            if match:
+                self._progress.append(typeconvert_groupdict(match))
+                return True
+
+        return False
+
+    def get_progress(self) -> list:
+        """Return the progress of the search tree."""
+        result = list(self._progress)
+        if "Runtime" in self._summary:
+            # Final statistics are added as a final tracked line.
+            result.append(
+                {
+                    "Incumbent": self._summary.get("ObjVal"),
+                    "BestBd": self._summary.get("ObjBound"),
+                    "Gap": self._summary.get("MIPGap"),
+                    "CurrentNode": self._summary.get("NodeCount"),
+                    "Time": self._summary.get("Runtime"),
+                }
+            )
+        return result
diff --git a/src/grblogtools/parsers/norel.py b/src/grblogtools/parsers/norel.py
new file mode 100644
index 0000000..618b1c0
--- /dev/null
+++ b/src/grblogtools/parsers/norel.py
@@ -0,0 +1,73 @@
+import re
+
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class NoRelParser:
+    norel_log_start = re.compile(r"Starting NoRel heuristic")
+    norel_primal_regex = re.compile(
+        r"Found heuristic solution:\sobjective\s(?P<Incumbent>[^\s]+)"
+    )
+    # Order is important in this list as regexes are checked in order
+    norel_elapsed = [
+        re.compile(
+            r"Elapsed time for NoRel heuristic:\s(?P<Time>\d+)s\s\(best\sbound\s(?P<BestBd>[^\s]+)\)"
+        ),
+        re.compile(r"Elapsed time for NoRel heuristic:\s(?P<Time>\d+)s"),
+    ]
+
+    def __init__(self):
+        self._progress = []
+        self._incumbent = None
+        self._started = False
+
+    def get_summary(self) -> dict:
+        """Return the summary based on the timeline information.
+
+        It assumes that the best bound is always found in the last line, if exists.
+        """
+        if not self._progress:
+            return {}
+        last_log = self._progress[-1]
+        result = {"NoRelTime": last_log["Time"]}
+        if "BestBd" in last_log:
+            result["NoRelBestBd"] = last_log["BestBd"]
+        if self._incumbent is not None:
+            result["NoRelBestSol"] = self._incumbent
+        return result
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary and progress data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        if not self._started:
+            match = self.norel_log_start.match(line)
+            if match:
+                self._started = True
+                return True
+            return False
+
+        match = self.norel_primal_regex.match(line)
+        if match:
+            self._incumbent = float(match.group("Incumbent"))
+            return True
+
+        for regex in self.norel_elapsed:
+            match = regex.match(line)
+            if match:
+                entry = typeconvert_groupdict(match)
+                if self._incumbent is not None:
+                    entry["Incumbent"] = self._incumbent
+                self._progress.append(entry)
+                return True
+
+        return False
+
+    def get_progress(self) -> list:
+        """Return the progress of the norel heuristic."""
+        return self._progress
diff --git a/src/grblogtools/parsers/presolve.py b/src/grblogtools/parsers/presolve.py
new file mode 100644
index 0000000..6062664
--- /dev/null
+++ b/src/grblogtools/parsers/presolve.py
@@ -0,0 +1,118 @@
+import re
+
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class PresolveParser:
+    # The pattern indicating the initialization of the parser
+    presolve_start_pattern = re.compile(
+        r"Optimize a model with (?P<NumConstrs>\d+) (R|r)ows, (?P<NumVars>\d+) (C|c)olumns and (?P<NumNZs>\d+) (N|n)on(Z|z)ero(e?)s"
+    )
+
+    # Possible intermediate patterns to be parsed
+    presolve_intermediate_patterns = [
+        re.compile(r"Model fingerprint: (?P<Fingerprint>.*)$"),
+        re.compile(
+            r"Variable types: (?P<PresolvedNumConVars>\d+) continuous, (?P<PresolvedNumIntVars>\d+) integer \((?P<PresolvedNumBinVars>\d+) binary\)$"
+        ),
+        re.compile(
+            r"Variable types: (?P<PresolvedNumBinVars>\d+) bin/(?P<PresolvedNumIntVars>\d+) gen[^/]*/(?P<PresolvedNumConVars>\d+) continuous"
+        ),
+        re.compile(
+            r"Semi-Variable types: (?P<PresolvedNumSemiContVars>\d+) continuous, (?P<PresolvedNumSemiIntVars>\d+) integer$"
+        ),
+        re.compile(
+            r"\s*QMatrix range\s*\[(?P<MinQCCoeff>[^,]+),\s*(?P<MaxQCCoeff>[^\]]+)\]"
+        ),
+        re.compile(
+            r"\s*QLMatrix range\s*\[(?P<MinQCLCoeff>[^,]+),\s*(?P<MaxQCLCoeff>[^\]]+)\]"
+        ),
+        re.compile(
+            r"\s*Matrix range\s*\[(?P<MinCoeff>[^,]+),\s*(?P<MaxCoeff>[^\]]+)\]"
+        ),
+        re.compile(
+            r"\s*QObjective range\s*\[(?P<MinQObjCoeff>[^,]+),\s*(?P<MaxQObjCoeff>[^\]]+)\]"
+        ),
+        re.compile(
+            r"\s*Objective range\s*\[(?P<MinObjCoeff>[^,]+),\s*(?P<MaxObjCoeff>[^\]]+)\]"
+        ),
+        re.compile(
+            r"\s*Bounds range\s*\[(?P<MinBound>[^,]+),\s*(?P<MaxBound>[^\]]+)\]"
+        ),
+        re.compile(r"\s*RHS range\s*\[(?P<MinRHS>[^,]+),\s*(?P<MaxRHS>[^\]]+)\]"),
+        re.compile(r"\s*QRHS range\s*\[(?P<MinQCRHS>[^,]+),\s*(?P<MaxQCRHS>[^\]]+)\]"),
+        re.compile(r"Model has (?P<NumQNZs>\d+) quadratic objective terms?"),
+        re.compile(r"Model has (?P<NumQConstrs>\d+) quadratic constraints?"),
+        re.compile(r"Model has (?P<NumSOS>\d+) SOS constraints?"),
+        re.compile(
+            r"Model has (?P<NumPWLObjVars>\d+) piecewise-linear objective terms?"
+        ),
+        re.compile(r"Model has (?P<NumGenConstrs>\d+) general constraints?"),
+        re.compile(r"Distributed MIP job count: (?P<DistributedMIPJobs>\d+)"),
+        re.compile(r"Concurrent MIP job count: (?P<ConcurrentJobs>\d+)"),
+        re.compile(
+            r"Concurrent MIP optimizer: (?P<ConcurrentJobs>\d+) concurrent instances \(\d+ threads per instance\)"
+        ),
+        re.compile(
+            r"Presolved: (?P<PresolvedNumConstrs>\d+) (R|r)ows, (?P<PresolvedNumVars>\d+) (C|c)olumns, (?P<PresolvedNumNZs>\d+) (N|n)on(Z|z)ero(e?)s"
+        ),
+        re.compile(r"Presolved model has (?P<PresolvedNumSOS>\d+) SOS constraint(s)\n"),
+        re.compile(
+            r"Presolved model has (?P<PresolvedNumQNZs>\d+) quadratic objective terms"
+        ),
+        re.compile(r"Presolve time: (?P<PresolveTime>[\d\.]+)s"),
+    ]
+
+    # Special case: model solved by presolve
+    presolve_all_removed = re.compile(r"Presolve: All rows and columns removed")
+
+    def __init__(self):
+        """Initialize the Presolve parser.
+
+        The PresolveParser extends beyond the lines associated with the presolved
+        model. Specifically, it includes information for all lines appearing between
+        the HeaderParser and the NoRelParser or the RelaxationParser.
+        """
+        self._summary = {}
+        self._started = False
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        if not self._started:
+            match = PresolveParser.presolve_start_pattern.match(line)
+            if match:
+                # The start line encodes information that should be stored
+                self._started = True
+                self._summary.update(typeconvert_groupdict(match))
+                return True
+            return False
+
+        for pattern in PresolveParser.presolve_intermediate_patterns:
+            match = pattern.match(line)
+            if match:
+                self._summary.update(typeconvert_groupdict(match))
+                return True
+
+        match = PresolveParser.presolve_all_removed.match(line)
+        if match:
+            self._summary.update(
+                {
+                    "PresolvedNumConstrs": 0,
+                    "PresolvedNumVars": 0,
+                    "PresolvedNumNZs": 0,
+                }
+            )
+            return True
+
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        return self._summary
diff --git a/src/grblogtools/parsers/simplex.py b/src/grblogtools/parsers/simplex.py
new file mode 100644
index 0000000..02a27b3
--- /dev/null
+++ b/src/grblogtools/parsers/simplex.py
@@ -0,0 +1,66 @@
+import re
+
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class SimplexParser:
+    # The pattern indicating the initialization of the parser
+    simplex_start_pattern = re.compile(
+        r"Iteration(\s+)Objective(\s+)Primal Inf.(\s+)Dual Inf.(\s+)Time"
+    )
+
+    # The pattern indicating the simplex progress
+    simplex_progress_pattern = re.compile(
+        r"\s*(?P<Iteration>\d+)\s+(?P<Objective>[^\s]+)\s+(?P<PInf>[^\s]+)\s+(?P<DInf>[^\s]+)\s+(?P<Time>\d+)s"
+    )
+
+    # The pattern indicating the termination of the simplex method
+    simplex_termination_pattern = re.compile(
+        r"(Solved|Stopped) in (?P<IterCount>[^\s]+) iterations and (?P<Runtime>[^\s]+) seconds"
+    )
+
+    def __init__(self):
+        """Initialize the Simplex parser."""
+        self._summary = {}
+        self._progress = []
+        self._started = False
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate summary and progress data.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        # Check this first since the termination line might appear
+        # without any progress log in the concurrent case.
+        match = SimplexParser.simplex_termination_pattern.match(line)
+        if match:
+            self._summary.update(typeconvert_groupdict(match))
+            return True
+
+        if not self._started:
+            match = SimplexParser.simplex_start_pattern.match(line)
+            if match:
+                self._started = True
+                return True
+            return False
+
+        progress_match = SimplexParser.simplex_progress_pattern.match(line)
+        if progress_match:
+            entry = {"Type": "simplex"}
+            entry.update(typeconvert_groupdict(progress_match))
+            self._progress.append(entry)
+            return True
+
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        return self._summary
+
+    def get_progress(self) -> list:
+        """Return the detailed progress in simplex method if exists."""
+        return self._progress
diff --git a/src/grblogtools/parsers/single_log.py b/src/grblogtools/parsers/single_log.py
new file mode 100644
index 0000000..f2b587e
--- /dev/null
+++ b/src/grblogtools/parsers/single_log.py
@@ -0,0 +1,88 @@
+from grblogtools.parsers.continuous import ContinuousParser
+from grblogtools.parsers.header import HeaderParser
+from grblogtools.parsers.nodelog import NodeLogParser
+from grblogtools.parsers.norel import NoRelParser
+from grblogtools.parsers.presolve import PresolveParser
+from grblogtools.parsers.termination import TerminationParser
+from grblogtools.parsers.util import model_type
+
+
+class SingleLogParser:
+    """This class parses one single log file.
+
+    It expects parse to be called once for each line in a log file.
+    """
+
+    def __init__(self):
+        # Parsers in sequence
+        self.header_parser = HeaderParser()
+        self.presolve_parser = PresolveParser()
+        self.norel_parser = NoRelParser()
+        self.continuous_parser = ContinuousParser()
+        self.nodelog_parser = NodeLogParser()
+        self.termination_parser = TerminationParser()
+
+        # State
+        self.started = False
+        self.current_parser = self.header_parser
+        self.future_parsers = [
+            self.presolve_parser,
+            self.norel_parser,
+            self.continuous_parser,
+            self.nodelog_parser,
+        ]
+
+    def get_summary(self):
+        """Return a summary dict, a merged result of the sub-parser results."""
+        summary = {}
+        summary.update(self.header_parser.get_summary())
+        summary.update(self.presolve_parser.get_summary())
+        summary.update(self.norel_parser.get_summary())
+        summary.update(self.continuous_parser.get_summary())
+        summary.update(self.nodelog_parser.get_summary())
+        summary.update(self.termination_parser.get_summary())
+        summary["ModelType"] = model_type(
+            discrete_vars=summary.get("PresolvedNumBinVars", 0)
+            + summary.get("PresolvedNumIntVars", 0)
+            + summary.get("PresolvedNumSemiContVars", 0)
+            + summary.get("PresolvedNumSemiIntVars", 0),
+            quad_nonzeros=summary.get("NumQNZs", 0),
+            quad_constrs=summary.get("NumQConstrs", 0),
+        )
+        return summary
+
+    def parse(self, line: str) -> bool:
+        """Parse the given log line to populate the component parsers in sequence.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        # Initially, only check the header parser until started
+        if not self.started:
+            assert self.current_parser is self.header_parser
+            matched = self.current_parser.parse(line)
+            if matched:
+                self.started = True
+            return matched
+
+        # First try the current parser
+        assert self.current_parser not in self.future_parsers
+        if self.current_parser.parse(line):
+            return True
+
+        # Check if any future parsers should take over
+        for i, parser in enumerate(self.future_parsers):
+            if parser.parse(line):
+                self.current_parser = parser
+                self.future_parsers = self.future_parsers[i + 1 :]
+                return True
+
+        # Check if the line matches any pattern of the termination parser
+        if self.termination_parser.parse(line):
+            return True
+
+        # Nothing matched
+        return False
diff --git a/src/grblogtools/parsers/termination.py b/src/grblogtools/parsers/termination.py
new file mode 100644
index 0000000..543055f
--- /dev/null
+++ b/src/grblogtools/parsers/termination.py
@@ -0,0 +1,75 @@
+import re
+
+from grblogtools.parsers.util import typeconvert_groupdict
+
+
+class TerminationParser:
+    # Termination patterns
+    patterns = [
+        re.compile(r"ERROR (?P<ErrorCode>[^:]+): (?P<ErrorMessage>.*)$"),
+        re.compile(r"\[(?P<ErrorMessage>process terminated with exit code [^\\]]+)\]$"),
+        re.compile(r"(?P<TIME_LIMIT>Time limit reached)"),
+        re.compile(r"(?P<OPTIMAL>Optimal solution found)(?: \(tolerance .*\))"),
+        re.compile(r"(?P<ITERATION_LIMIT>Iteration limit reached)"),
+        re.compile(r"(?P<INF_OR_UNBD>Infeasible or unbounded model)"),
+        re.compile(r"(?P<INF_OR_UNBD>Model is infeasible or unbounded)"),
+        re.compile(r"(?P<UNBOUNDED>Unbounded model)"),
+        re.compile(r"(?P<UNBOUNDED>Model is unbounded)"),
+        re.compile(r"(?P<INFEASIBLE>Infeasible model)"),
+        re.compile(r"(?P<INFEASIBLE>Model is infeasible)"),
+        re.compile(r"(?P<SOLUTION_LIMIT>Solution limit reached)"),
+        re.compile(r"(?P<NODE_LIMIT>Node limit reached)"),
+        re.compile(r"(?P<NUMERIC>Numeric error)"),
+        re.compile(r"(?P<NUMERIC>Numerical trouble encountered)"),
+        re.compile(r"(?P<CUTOFF>Model objective exceeds cutoff)"),
+        re.compile(r"(?P<CUTOFF>Objective cutoff exceeded)"),
+        re.compile(r"(?P<USER_OBJ_LIMIT>Optimization achieved user objective limit)"),
+        re.compile(
+            r"(?P<INTERRUPTED>(Interrupt request received|Solve interrupted))(?: \\(error code (?P<ErrorCode>[^\\)]+)\\))?"
+        ),
+        re.compile(r"Solution count (?P<SolCount>\d+)"),
+        re.compile(
+            r"Thread count was (?P<Threads>\d+) \(of (?P<Cores>\d+) available processors\)"
+        ),
+    ]
+
+    status = [
+        "OPTIMAL",
+        "TIME_LIMIT",
+        "ITERATION_LIMIT",
+        "INF_OR_UNBD",
+        "UNBOUNDED",
+        "INFEASIBLE",
+        "SOLUTION_LIMIT",
+        "NUMERIC",
+        "CUTOFF",
+        "USER_OBJ_LIMIT",
+        "INTERRUPTED",
+    ]
+
+    def __init__(self):
+        self._summary = {}
+
+    def parse(self, line: str) -> bool:
+        """Return True if the line is matched by some pattern.
+
+        Args:
+            line (str): A line in the log file.
+
+        Returns:
+            bool: Return True if the given line is matched by some pattern.
+        """
+        for pattern in TerminationParser.patterns:
+            match = pattern.match(line)
+            if match:
+                for key, value in typeconvert_groupdict(match).items():
+                    if key in TerminationParser.status:
+                        self._summary.update({"Status": key})
+                    else:
+                        self._summary.update({key: value})
+                return True
+        return False
+
+    def get_summary(self) -> dict:
+        """Return the current parsed summary."""
+        return self._summary
diff --git a/src/grblogtools/parsers/util.py b/src/grblogtools/parsers/util.py
new file mode 100644
index 0000000..bdc23d7
--- /dev/null
+++ b/src/grblogtools/parsers/util.py
@@ -0,0 +1,65 @@
+import datetime
+import re
+from typing import Iterable
+
+int_regex = re.compile(r"[-+]?\d+$")
+float_regex = re.compile(r"[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?$")
+percentage_regex = re.compile(r"[-+]?((\d*\.\d+)|(\d+\.?))([Ee][+-]?\d+)?%$")
+date_time_regex = re.compile(r"\D+\s\D+\s\d+\s\d+:\d+:\d+\s\d{4}")
+
+
+def convert_data_types(value):
+    """Convert the given value string to the type it matches."""
+    if value is None or value == "-":
+        # Commonly used sentinel for a missing value in log tables
+        return None
+    elif int_regex.match(value):
+        return int(value)
+    elif float_regex.match(value):
+        return float(value)
+    elif percentage_regex.match(value):
+        return float(value.rstrip("%")) / 100
+    elif date_time_regex.match(value):
+        return datetime.datetime.strptime(value, "%a %b %d %H:%M:%S %Y")
+    else:
+        return value
+
+
+def typeconvert_groupdict(match: re.Match):
+    """Return the groupdict of a regex match with type converted values."""
+    return {k: convert_data_types(v) for k, v in match.groupdict().items()}
+
+
+def parse_lines(parser, loglines: Iterable[str]):
+    """Parse the given lines using the given parser object.
+
+    This function is mainly used in the tests.
+    """
+    lines = iter(loglines)
+    for line in lines:
+        parser.parse(line)
+
+
+def parse_block(parser, log):
+    """Parse a multi-line block of text using the given parser object.
+
+    This function is mainly used in the tests.
+    """
+    parse_lines(parser, log.strip().split("\n"))
+
+
+def model_type(discrete_vars=0, quad_nonzeros=0, quad_constrs=0):
+    """Return the type of the optimization model.
+
+    The model type is MIP, QCP, QP, or LP.
+    """
+    mtype = ""
+    if discrete_vars > 0:
+        mtype += "MI"
+    if quad_constrs > 0:
+        mtype += "QC"
+    elif quad_nonzeros > 0:
+        mtype += "Q"
+    if not mtype:
+        return "LP"
+    return mtype + "P"
diff --git a/src/grblogtools/plotting.py b/src/grblogtools/plotting.py
new file mode 100755
index 0000000..69ff96a
--- /dev/null
+++ b/src/grblogtools/plotting.py
@@ -0,0 +1,99 @@
+import ipywidgets as widgets
+import pandas as pd
+import plotly.express as px
+from ipywidgets import interact
+
+
+def plot(df: pd.DataFrame, points="all", barmode="group", **kwargs):
+    """plot different chart types to compare performance and see performance variability across random seeds
+
+    uses Plotly express; all available keyword arguments can be passed through to px.bar(), px.scatter(), etc.
+    """
+
+    options = list(df.columns) + [None]
+
+    if "Incumbent" in options:
+        # read custom axis data or use common defaults for summary DataFrame
+        x_default = kwargs.pop("x", "Time")
+        y_default = kwargs.pop("y", "Incumbent")
+        color_default = kwargs.pop("color", "Log")
+    else:
+        # read custom axis data or use common defaults for summary DataFrame
+        x_default = kwargs.pop("x", "Log")
+        y_default = kwargs.pop("y", "Runtime")
+        color_default = kwargs.pop("color", "Seed")
+
+    # set type to None to disable plotting until a type is selected
+    type_default = kwargs.pop("type", None)
+
+    # pop values for remaining widgets to avoid double inputs
+    symbol_default = kwargs.pop("symbol", None)
+    log_x_default = kwargs.pop("log_x", False)
+    log_y_default = kwargs.pop("log_y", False)
+
+    # check wether selected keys are available in DataFrame
+    if x_default not in options:
+        x_default = None
+    if y_default not in options:
+        y_default = None
+    if color_default not in options:
+        color_default = None
+
+    switches = dict(
+        x=widgets.Dropdown(options=options, value=x_default),
+        y=widgets.Dropdown(options=options, value=y_default),
+        color=widgets.Dropdown(options=options, value=color_default),
+        type=widgets.Dropdown(
+            options=["box", "bar", "scatter", "line"], value=type_default
+        ),
+        symbol=widgets.Dropdown(options=options, value=symbol_default),
+        log_x=widgets.Checkbox(value=log_x_default),
+        log_y=widgets.Checkbox(value=log_y_default),
+    )
+
+    @interact(**switches)
+    def _(x, y, color, type, symbol, log_x, log_y):
+        if type == "box":
+            return px.box(
+                df,
+                x=x,
+                y=y,
+                color=color,
+                points=points,
+                log_x=log_x,
+                log_y=log_y,
+                **kwargs,
+            )
+        elif type == "bar":
+            return px.bar(
+                df,
+                x=x,
+                y=y,
+                color=color,
+                barmode=barmode,
+                log_x=log_x,
+                log_y=log_y,
+                **kwargs,
+            )
+        elif type == "scatter":
+            return px.scatter(
+                df,
+                x=x,
+                y=y,
+                color=color,
+                symbol=symbol,
+                log_x=log_x,
+                log_y=log_y,
+                **kwargs,
+            )
+        elif type == "line":
+            return px.line(
+                df,
+                x=x,
+                y=y,
+                color=color,
+                symbol=symbol,
+                log_x=log_x,
+                log_y=log_y,
+                **kwargs,
+            )
diff --git a/tox.ini b/tox.ini
index 15858ed..2cc0667 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,14 +4,20 @@
 # and then run "tox" from this directory.
 
 [tox]
-envlist = py36,py37,py38,py39
+envlist = py37,py38,py39,py310,pre-commit
+isolated_build = True
 
 [gh-actions]
 python =
-    3.6: py36
     3.7: py37
     3.8: py38
-    3.9: py39
+    3.9: py39,pre-commit
+    3.10: py310
+
+[testenv:pre-commit]
+skip_install = true
+deps = pre-commit
+commands = pre-commit run --all-files --show-diff-on-failure
 
 [testenv]
 deps =
@@ -20,4 +26,4 @@ commands =
     pytest
     python -m grblogtools test.xlsx data/*.log
     python -m grblogtools --timelines test.xlsx data/*.log
-    python -m grblogtools -t -m test.xlsx data/*.log
+    python -m grblogtools -t test.xlsx data/*.log


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/assets/gurobipy/empty.log b/tests/assets/gurobipy/empty.log
new file mode 100644
index 0000000..07d137e
--- /dev/null
+++ b/tests/assets/gurobipy/empty.log
@@ -0,0 +1,18 @@
+Restricted license - for non-production use only - expires 2023-10-25
+Set parameter TimeLimit to value 10
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[x86])
+Thread count: 4 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 0 rows, 0 columns and 0 nonzeros
+Model fingerprint: 0xf9715da1
+Coefficient statistics:
+  Matrix range     [0e+00, 0e+00]
+  Objective range  [0e+00, 0e+00]
+  Bounds range     [0e+00, 0e+00]
+  RHS range        [0e+00, 0e+00]
+Presolve time: 0.00s
+Presolve: All rows and columns removed
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+       0    0.0000000e+00   0.000000e+00   0.000000e+00      0s
+
+Solved in 0 iterations and 0.00 seconds (0.00 work units)
+Optimal objective  0.000000000e+00
diff --git a/tests/assets/gurobipy/read.log b/tests/assets/gurobipy/read.log
new file mode 100644
index 0000000..f7e4253
--- /dev/null
+++ b/tests/assets/gurobipy/read.log
@@ -0,0 +1,23 @@
+Restricted license - for non-production use only - expires 2023-10-25
+Read MPS format model from file /tmp/afiro.mps
+Reading time = 0.00 seconds
+AFIRO: 27 rows, 32 columns, 83 nonzeros
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[x86])
+Thread count: 4 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 27 rows, 32 columns and 83 nonzeros
+Model fingerprint: 0x092217cc
+Coefficient statistics:
+  Matrix range     [1e-01, 2e+00]
+  Objective range  [3e-01, 1e+01]
+  Bounds range     [0e+00, 0e+00]
+  RHS range        [4e+01, 5e+02]
+Presolve removed 18 rows and 20 columns
+Presolve time: 0.00s
+Presolved: 9 rows, 12 columns, 32 nonzeros
+
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+       0   -5.9751945e+02   5.541356e+01   0.000000e+00      0s
+       6   -4.6475314e+02   0.000000e+00   0.000000e+00      0s
+
+Solved in 6 iterations and 0.00 seconds (0.00 work units)
+Optimal objective -4.647531429e+02
diff --git a/tests/assets/lp_barrier.log b/tests/assets/lp_barrier.log
new file mode 100644
index 0000000..35b65d4
--- /dev/null
+++ b/tests/assets/lp_barrier.log
@@ -0,0 +1,75 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Thu Jan  6 17:09:27 2022
+
+Set parameter TimeLimit to value 300
+Set parameter Method to value 2
+Set parameter CrossoverBasis to value 1
+Set parameter LogFile to value "barrier.log"
+Using license file /Users/aramon/gurobi_named_user_0.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read MPS format model from file savsched1.mps.bz2
+Reading time = 0.80 seconds
+savsched1.mps: 295989 rows, 328575 columns, 1770507 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 295989 rows, 328575 columns and 1770507 nonzeros
+Model fingerprint: 0x7926122d
+Coefficient statistics:
+  Matrix range     [1e+00, 1e+00]
+  Objective range  [1e-01, 1e+01]
+  Bounds range     [1e+00, 8e+00]
+  RHS range        [1e+00, 2e+02]
+Presolve removed 6996 rows and 18897 columns
+Presolve time: 0.52s
+Presolved: 288993 rows, 309678 columns, 1744764 nonzeros
+
+Ordering time: 0.41s
+
+Barrier statistics:
+ AA' NZ     : 4.465e+06
+ Factor NZ  : 1.784e+07 (roughly 400 MB of memory)
+ Factor Ops : 5.403e+09 (less than 1 second per iteration)
+ Threads    : 8
+
+                  Objective                Residual
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+   0   4.56435085e+07  1.53061018e+04  1.69e+05 8.58e+00  1.59e+03     2s
+   1   3.76722276e+07 -5.41297282e+05  8.07e+04 9.12e+00  8.17e+02     2s
+   2   3.14922356e+07 -1.01224789e+06  3.38e+04 6.54e+00  3.99e+02     2s
+   3   1.21152751e+07 -1.49525364e+06  4.37e+03 2.73e+00  8.92e+01     2s
+   4   1.86897763e+06 -1.72557346e+06  1.72e+02 9.69e-01  8.85e+00     2s
+   5   4.40699989e+05 -6.85397384e+05  6.07e+00 9.67e-02  1.69e+00     3s
+   6   1.30285684e+05 -2.46488270e+05  5.20e+00 3.12e-02  5.01e-01     3s
+   7   7.18485003e+04 -1.34436525e+05  2.73e+00 1.53e-02  2.67e-01     3s
+   8   3.04314095e+04 -5.24190921e+04  1.08e+00 5.36e-03  1.05e-01     3s
+   9   1.64582736e+04 -2.42722287e+04  5.79e-01 2.15e-03  5.10e-02     3s
+  10   9.93380400e+03 -1.07953562e+04  3.58e-01 7.85e-04  2.58e-02     3s
+  11   4.06649322e+03 -4.21823293e+03  9.82e-02 2.57e-04  1.03e-02     4s
+  12   1.63214733e+03 -5.23194275e+02  8.89e-04 2.59e-05  2.67e-03     4s
+  13   5.40752037e+02 -8.31322931e+01  2.00e-04 7.18e-06  7.71e-04     4s
+  14   2.80551774e+02  1.68630146e+02  3.90e-05 8.81e-07  1.38e-04     4s
+  15   2.17512674e+02  2.16883985e+02  7.99e-14 0.00e+00  7.71e-07     4s
+  16   2.17403681e+02  2.17403051e+02  3.57e-14 7.11e-15  7.71e-10     5s
+  17   2.17403572e+02  2.17403571e+02  3.93e-14 7.11e-15  7.71e-13     5s
+
+Barrier solved model in 17 iterations and 4.83 seconds (6.45 work units)
+Optimal objective 2.17403572e+02
+
+Crossover log...
+
+   57249 DPushes remaining with DInf 0.0000000e+00                 8s
+       0 DPushes remaining with DInf 0.0000000e+00                 9s
+
+    9342 PPushes remaining with PInf 1.2118572e-05                 9s
+       0 PPushes remaining with PInf 0.0000000e+00                 9s
+
+  Push phase complete: Pinf 0.0000000e+00, Dinf 1.8540725e-14      9s
+
+Extra simplex iterations after uncrush: 1
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+   51765    2.1740357e+02   0.000000e+00   0.000000e+00      9s
+
+Solved in 51765 iterations and 9.18 seconds (13.22 work units)
+Optimal objective  2.174035714e+02
diff --git a/tests/assets/lp_concurrent.log b/tests/assets/lp_concurrent.log
new file mode 100644
index 0000000..b061504
--- /dev/null
+++ b/tests/assets/lp_concurrent.log
@@ -0,0 +1,56 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Thu Jan  6 17:18:22 2022
+
+Set parameter TimeLimit to value 300
+Set parameter Method to value 3
+Set parameter LogFile to value "concurrent.log"
+Using license file /Users/aramon/gurobi_named_user_0.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read MPS format model from file examples/savsched1.mps.bz2
+Reading time = 0.80 seconds
+savsched1.mps: 295989 rows, 328575 columns, 1770507 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 295989 rows, 328575 columns and 1770507 nonzeros
+Model fingerprint: 0x7926122d
+Coefficient statistics:
+  Matrix range     [1e+00, 1e+00]
+  Objective range  [1e-01, 1e+01]
+  Bounds range     [1e+00, 8e+00]
+  RHS range        [1e+00, 2e+02]
+
+Concurrent LP optimizer: primal simplex, dual simplex, and barrier
+Showing barrier log only...
+
+Presolve removed 6996 rows and 18897 columns
+Presolve time: 0.76s
+Presolved: 288993 rows, 309678 columns, 1744764 nonzeros
+
+Ordering time: 0.53s
+
+Barrier statistics:
+ AA' NZ     : 4.465e+06
+ Factor NZ  : 1.784e+07 (roughly 400 MB of memory)
+ Factor Ops : 5.403e+09 (less than 1 second per iteration)
+ Threads    : 6
+
+                  Objective                Residual
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+   0   4.56435085e+07  1.53061018e+04  1.69e+05 8.58e+00  1.59e+03     2s
+   1   3.76722276e+07 -5.41297282e+05  8.07e+04 9.12e+00  8.17e+02     2s
+   2   3.14922356e+07 -1.01224789e+06  3.38e+04 6.54e+00  3.99e+02     3s
+   3   1.21152751e+07 -1.49525364e+06  4.37e+03 2.73e+00  8.92e+01     3s
+   4   1.86897763e+06 -1.72557346e+06  1.72e+02 9.69e-01  8.85e+00     3s
+   5   4.40699989e+05 -6.85397384e+05  6.07e+00 9.67e-02  1.69e+00     4s
+   6   1.30285684e+05 -2.46488270e+05  5.20e+00 3.12e-02  5.01e-01     4s
+   7   7.18485003e+04 -1.34436525e+05  2.73e+00 1.53e-02  2.67e-01     4s
+
+Barrier performed 7 iterations in 4.02 seconds (4.19 work units)
+Barrier solve interrupted - model solved by another algorithm
+
+
+Solved with dual simplex
+Solved in 69986 iterations and 4.03 seconds (3.91 work units)
+Optimal objective  2.174035714e+02
diff --git a/tests/assets/lp_simplex.log b/tests/assets/lp_simplex.log
new file mode 100644
index 0000000..2df01ed
--- /dev/null
+++ b/tests/assets/lp_simplex.log
@@ -0,0 +1,90 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Thu Jan  6 17:13:12 2022
+
+Set parameter TimeLimit to value 300
+Set parameter Method to value 0
+Set parameter LogFile to value "simplex.log"
+Using license file /Users/aramon/gurobi_named_user_0.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read MPS format model from file examples/datt256.mps.bz2
+Reading time = 0.61 seconds
+datt256.mps.gz: 11077 rows, 262144 columns, 1503732 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 11077 rows, 262144 columns and 1503732 nonzeros
+Model fingerprint: 0x80123a8b
+Coefficient statistics:
+  Matrix range     [1e+00, 2e+00]
+  Objective range  [1e+00, 1e+00]
+  Bounds range     [1e+00, 1e+00]
+  RHS range        [1e+00, 1e+00]
+Presolve removed 1218 rows and 66001 columns
+Presolve time: 0.55s
+Presolved: 9859 rows, 196143 columns, 1124557 nonzeros
+
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+       0    1.3200000e+02   1.744600e+04   2.716220e+13      1s
+    4091    2.5219335e+02   8.737955e+02   1.251147e+12      6s
+    6231    2.5504322e+02   8.021529e+02   5.404958e+11     11s
+    7871    2.5537406e+02   7.513884e+02   4.615432e+11     15s
+    9481    2.5620200e+02   7.094663e+02   3.931134e+11     21s
+   11071    2.5670327e+02   6.747471e+02   4.106594e+11     26s
+   12671    2.5748414e+02   6.368423e+02   3.203927e+11     31s
+   14241    2.5728506e+02   6.062970e+02   4.973163e+11     36s
+   15801    2.5655360e+02   5.747749e+02   7.590480e+11     41s
+   16971    2.5605367e+02   5.533897e+02   5.974781e+11     45s
+   18491    2.5650110e+02   5.269211e+02   2.673797e+11     51s
+   19641    2.5681115e+02   5.080923e+02   3.812134e+11     55s
+   21141    2.5706545e+02   4.843323e+02   2.800457e+11     61s
+   22271    2.5691920e+02   4.701448e+02   7.142253e+11     65s
+   23821    2.5698373e+02   4.503326e+02   3.991477e+11     71s
+   24931    2.5691422e+02   4.382209e+02   5.130079e+11     76s
+   26031    2.5676470e+02   4.270793e+02   5.213610e+11     80s
+   27531    2.5685903e+02   4.118371e+02   7.359646e+11     86s
+   28641    2.5668862e+02   4.009972e+02   3.509006e+11     91s
+   29781    2.5667414e+02   3.905334e+02   3.335097e+11     96s
+   30881    2.5676134e+02   3.802632e+02   3.833581e+11    100s
+   31991    2.5674904e+02   3.711115e+02   5.508078e+11    105s
+   33101    2.5670315e+02   3.602912e+02   3.564287e+11    110s
+   34571    2.5662839e+02   3.464632e+02   9.038256e+11    116s
+   35651    2.5676794e+02   3.369193e+02   8.664239e+11    121s
+   36731    2.5682963e+02   3.282196e+02   3.117649e+11    126s
+   37821    2.5690720e+02   3.197568e+02   6.206158e+11    130s
+   39281    2.5707761e+02   3.107982e+02   2.750024e+11    137s
+   40381    2.5708301e+02   3.041455e+02   3.601885e+11    141s
+   41521    2.5710127e+02   2.981656e+02   3.762941e+11    146s
+   42641    2.5702207e+02   2.918380e+02   4.157175e+11    151s
+   43721    2.5700072e+02   2.863107e+02   3.118690e+11    156s
+   44811    2.5702648e+02   2.813448e+02   2.571040e+11    161s
+   45901    2.5703550e+02   2.769520e+02   2.654632e+11    165s
+   46981    2.5701652e+02   2.713343e+02   5.878934e+11    170s
+   48431    2.5704297e+02   2.634107e+02   4.850954e+11    176s
+   49521    2.5702226e+02   2.586121e+02   7.682626e+11    181s
+   50601    2.5705392e+02   2.545179e+02   7.942713e+11    186s
+   51691    2.5704422e+02   2.503485e+02   2.239523e+11    191s
+   52771    2.5706687e+02   2.461081e+02   3.651385e+11    196s
+   53871    2.5702991e+02   2.417508e+02   2.301713e+11    201s
+   54951    2.5702090e+02   2.383457e+02   4.194128e+11    206s
+   56031    2.5699878e+02   2.349154e+02   5.736423e+11    211s
+   57131    2.5695048e+02   2.314419e+02   5.808454e+11    216s
+   58221    2.5689535e+02   2.281745e+02   3.276185e+11    221s
+   59311    2.5688245e+02   2.251692e+02   2.948092e+11    226s
+   60401    2.5689809e+02   2.223120e+02   3.343573e+11    231s
+   61481    2.5685909e+02   2.188619e+02   2.915619e+11    236s
+   62561    2.5686816e+02   2.152823e+02   3.267517e+11    241s
+   63641    2.5689090e+02   2.110481e+02   5.842444e+11    246s
+   64721    2.5687458e+02   2.065608e+02   6.911933e+11    251s
+   65801    2.5684668e+02   2.028743e+02   3.560391e+11    256s
+   66881    2.5682312e+02   2.000307e+02   2.617141e+11    261s
+   67961    2.5680007e+02   1.978757e+02   3.123556e+11    266s
+   69041    2.5677979e+02   1.956253e+02   5.876315e+11    271s
+   70121    2.5680029e+02   1.933027e+02   2.421288e+11    276s
+   71201    2.5679340e+02   1.907956e+02   3.746574e+11    281s
+   72281    2.5676790e+02   1.882622e+02   2.636885e+11    286s
+   73361    2.5676266e+02   1.861702e+02   3.557608e+11    291s
+   74441    2.5676235e+02   1.835518e+02   3.541393e+11    296s
+
+Stopped in 75321 iterations and 300.00 seconds (800.21 work units)
+Time limit reached
diff --git a/tests/assets/mip.log b/tests/assets/mip.log
new file mode 100644
index 0000000..b71cd9b
--- /dev/null
+++ b/tests/assets/mip.log
@@ -0,0 +1,243 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Tue Jan 25 17:11:14 2022
+
+Set parameter TimeLimit to value 900
+Set parameter Method to value 2
+Set parameter LogFile to value "mip.log"
+Using license file /Users/x/gurobi.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Warning: mps file contains small (< 1e-13) coefficients, ignored
+Read MPS format model from file momentum3.mps.gz
+Reading time = 0.17 seconds
+momentum3.mps: 56822 rows, 13532 columns, 588073 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 56822 rows, 13532 columns and 588073 nonzeros
+Model fingerprint: 0xcf3efdd8
+Variable types: 6933 continuous, 6599 integer (0 binary)
+Coefficient statistics:
+  Matrix range     [1e-13, 2e+01]
+  Objective range  [5e-02, 1e+03]
+  Bounds range     [2e-01, 2e+02]
+  RHS range        [1e-06, 2e+02]
+Found heuristic solution: objective 525000.00000
+Presolve removed 12376 rows and 87 columns
+Presolve time: 1.63s
+Presolved: 44446 rows, 13445 columns, 495740 nonzeros
+Variable types: 6993 continuous, 6452 integer (6451 binary)
+Root barrier log...
+
+Ordering time: 1.14s
+Ordering time: 1.27s
+
+Barrier statistics:
+ Dense cols : 201
+ AA' NZ     : 1.977e+07
+ Factor NZ  : 8.279e+07 (roughly 700 MB of memory)
+ Factor Ops : 5.196e+11 (roughly 1 second per iteration)
+ Threads    : 8
+
+                  Objective                Residual
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+   0   4.86463581e+06 -1.30471069e+08  7.02e+02 1.00e+00  4.97e+04     9s
+   1   3.13237056e+06 -1.43636295e+08  4.08e+02 1.16e+03  2.58e+04    12s
+   2   2.31892437e+06 -1.27549140e+08  2.91e+02 2.55e+02  1.58e+04    15s
+   3   4.58425844e+05 -8.79225494e+07  4.82e+01 2.59e+01  3.33e+03    19s
+   4   2.00813609e+05 -3.07297304e+07  6.40e+00 1.37e-09  6.56e+02    23s
+   5   1.76837736e+05 -1.31786225e+07  2.55e+00 4.53e-10  2.50e+02    26s
+   6   1.56963641e+05 -3.44238199e+06  2.65e-01 8.37e-11  5.42e+01    30s
+   7   1.48413596e+05 -1.06649816e+06  7.88e-02 2.76e-11  1.76e+01    33s
+   8   1.34536474e+05 -1.20226765e+05  1.45e-05 7.79e-12  3.62e+00    37s
+   9   1.10347277e+05  4.34240772e+04  4.60e-06 3.83e-12  9.51e-01    40s
+  10   1.04513492e+05  6.36116125e+04  3.14e-06 4.34e-12  5.81e-01    44s
+  11   1.00197588e+05  7.69208277e+04  1.99e-06 3.28e-12  3.31e-01    47s
+  12   9.67427540e+04  8.44838350e+04  1.01e-06 8.10e-12  1.74e-01    51s
+  13   9.52255402e+04  8.86563497e+04  6.49e-07 5.26e-12  9.33e-02    54s
+  14   9.45888483e+04  9.02434079e+04  4.93e-07 6.46e-11  6.17e-02    58s
+  15   9.41820610e+04  9.15694715e+04  3.05e-07 1.03e-11  3.71e-02    61s
+  16   9.39145028e+04  9.25757385e+04  1.18e-07 1.43e-10  1.90e-02    65s
+  17   9.36979461e+04  9.32796950e+04  3.80e-08 1.77e-10  5.94e-03    69s
+  18   9.36143950e+04  9.34103091e+04  1.29e-08 8.80e-11  2.90e-03    72s
+  19   9.35971205e+04  9.34810815e+04  9.80e-09 4.17e-11  1.65e-03    75s
+  20   9.35824919e+04  9.34990283e+04  7.49e-09 2.27e-11  1.19e-03    79s
+  21   9.35613019e+04  9.35090066e+04  4.16e-09 2.41e-11  7.43e-04    82s
+  22   9.35474353e+04  9.35208133e+04  2.11e-09 1.04e-10  3.78e-04    86s
+  23   9.35369896e+04  9.35251228e+04  8.44e-10 3.32e-10  1.69e-04    90s
+  24   9.35343700e+04  9.35268691e+04  5.60e-10 4.27e-10  1.07e-04    93s
+  25   9.35323142e+04  9.35275712e+04  3.23e-10 4.49e-10  6.74e-05    97s
+  26   9.35315300e+04  9.35283115e+04  2.41e-10 4.80e-10  4.57e-05   100s
+  27   9.35306611e+04  9.35288057e+04  1.49e-10 7.22e-10  2.63e-05   104s
+  28   9.35294622e+04  9.35290701e+04  1.99e-11 5.50e-10  5.57e-06   108s
+  29   9.35293909e+04  9.35291339e+04  1.04e-09 2.50e-10  3.65e-06   111s
+  30   9.35293171e+04  9.35291751e+04  1.24e-09 4.42e-10  2.02e-06   115s
+  31   9.35292487e+04  9.35291996e+04  5.66e-09 2.06e-10  6.97e-07   119s
+  32   9.35292254e+04  9.35292115e+04  3.34e-09 1.89e-10  1.97e-07   122s
+  33   9.35292158e+04  9.35292148e+04  5.85e-09 2.48e-10  1.49e-08   126s
+  34   9.35292153e+04  9.35292152e+04  6.58e-09 1.26e-10  1.63e-09   130s
+
+Barrier solved model in 34 iterations and 129.72 seconds (266.27 work units)
+Optimal objective 9.35292153e+04
+
+
+Root crossover log...
+
+   15377 DPushes remaining with DInf 4.1212817e-02               130s
+    1232 DPushes remaining with DInf 2.4035060e-03               130s
+       0 DPushes remaining with DInf 3.0197351e-01               130s
+
+     468 PPushes remaining with PInf 1.4668106e-03               130s
+       0 PPushes remaining with PInf 0.0000000e+00               130s
+
+  Push phase complete: Pinf 0.0000000e+00, Dinf 1.0410863e-01    130s
+
+
+Root simplex log...
+
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+    4242    9.3529215e+04   0.000000e+00   1.041086e-01    130s
+    4305    9.3529215e+04   0.000000e+00   0.000000e+00    130s
+    4305    9.3529215e+04   0.000000e+00   0.000000e+00    130s
+
+Root relaxation: objective 9.352922e+04, 4305 iterations, 128.70 seconds (268.84 work units)
+
+    Nodes    |    Current Node    |     Objective Bounds      |     Work
+ Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time
+
+     0     0 94252.2434    0 1082 525000.000 94252.2434  82.0%     -  144s
+H    0     0                    424226.34236 94252.2434  77.8%     -  152s
+H    0     0                    369010.45537 94252.2434  74.5%     -  156s
+     0     0 94268.8678    0 1270 369010.455 94268.8678  74.5%     -  175s
+H    0     0                    342697.17407 94268.8678  72.5%     -  178s
+     0     0 94278.1606    0 1136 342697.174 94278.1606  72.5%     -  191s
+     0     0 94278.4334    0 1171 342697.174 94278.4334  72.5%     -  192s
+     0     0 94278.4484    0 1142 342697.174 94278.4484  72.5%     -  192s
+     0     0 94278.4500    0 1142 342697.174 94278.4500  72.5%     -  192s
+     0     0 94495.4721    0 1201 342697.174 94495.4721  72.4%     -  227s
+H    0     0                    332189.25651 94495.4721  71.6%     -  231s
+     0     0 94545.4510    0 1269 332189.257 94545.4510  71.5%     -  241s
+     0     0 94636.9271    0 1319 332189.257 94636.9271  71.5%     -  245s
+     0     0 94637.7764    0 1330 332189.257 94637.7764  71.5%     -  246s
+     0     0 94637.8999    0 1336 332189.257 94637.8999  71.5%     -  247s
+     0     0 94638.1474    0 1369 332189.257 94638.1474  71.5%     -  247s
+     0     0 94638.3619    0 1368 332189.257 94638.3619  71.5%     -  247s
+     0     0 94638.3769    0 1366 332189.257 94638.3769  71.5%     -  248s
+     0     0 94891.3957    0 1337 332189.257 94891.3957  71.4%     -  264s
+H    0     0                    326787.73465 94891.3957  71.0%     -  268s
+     0     0 95014.8695    0 1272 326787.735 95014.8695  70.9%     -  275s
+     0     0 95032.3383    0 1256 326787.735 95032.3383  70.9%     -  278s
+     0     0 95035.5111    0 1334 326787.735 95035.5111  70.9%     -  280s
+     0     0 95036.2761    0 1343 326787.735 95036.2761  70.9%     -  280s
+     0     0 95036.6259    0 1318 326787.735 95036.6259  70.9%     -  281s
+     0     0 95036.6542    0 1358 326787.735 95036.6542  70.9%     -  281s
+     0     0 95281.1079    0 1143 326787.735 95281.1079  70.8%     -  296s
+H    0     0                    323288.20562 95281.1079  70.5%     -  302s
+     0     0 95299.5948    0 1339 323288.206 95299.5948  70.5%     -  306s
+     0     0 95301.7739    0 1395 323288.206 95301.7739  70.5%     -  308s
+     0     0 95302.0188    0 1315 323288.206 95302.0188  70.5%     -  308s
+     0     0 95351.5023    0 1231 323288.206 95351.5023  70.5%     -  319s
+H    0     0                    321785.55304 95351.5023  70.4%     -  322s
+     0     0 95358.0901    0 1224 321785.553 95358.0901  70.4%     -  324s
+     0     0 95358.7137    0 1269 321785.553 95358.7137  70.4%     -  325s
+     0     0 95358.8440    0 1275 321785.553 95358.8440  70.4%     -  325s
+     0     0 95372.0323    0 1356 321785.553 95372.0323  70.4%     -  331s
+H    0     0                    321785.53445 95372.0323  70.4%     -  335s
+     0     0 95374.7089    0 1357 321785.534 95374.7089  70.4%     -  337s
+     0     0 95375.3593    0 1337 321785.534 95375.3593  70.4%     -  338s
+     0     0 95375.3923    0 1339 321785.534 95375.3923  70.4%     -  338s
+     0     0 95410.4135    0 1284 321785.534 95410.4135  70.3%     -  345s
+H    0     0                    314381.05058 95410.4135  69.7%     -  348s
+     0     0 95420.4722    0 1308 314381.051 95420.4722  69.6%     -  350s
+     0     0 95424.0131    0 1279 314381.051 95424.0131  69.6%     -  353s
+     0     0 95425.0810    0 1320 314381.051 95425.0810  69.6%     -  354s
+     0     0 95425.7043    0 1363 314381.051 95425.7043  69.6%     -  355s
+     0     0 95425.7045    0 1368 314381.051 95425.7045  69.6%     -  355s
+     0     0 95443.2268    0 1329 314381.051 95443.2268  69.6%     -  361s
+H    0     0                    303879.12119 95443.2268  68.6%     -  369s
+     0     0 95447.0983    0 1379 303879.121 95447.0983  68.6%     -  371s
+     0     0 95447.3431    0 1394 303879.121 95447.3431  68.6%     -  372s
+     0     0 95451.1370    0 1381 303879.121 95451.1370  68.6%     -  376s
+H    0     0                    301378.04451 95451.1370  68.3%     -  382s
+     0     0 95451.5076    0 1399 301378.045 95451.5076  68.3%     -  384s
+     0     0 95454.4170    0 1363 301378.045 95454.4170  68.3%     -  387s
+     0     0 95455.1395    0 1376 301378.045 95455.1395  68.3%     -  395s
+     0     0 95455.5424    0 1381 301378.045 95455.5424  68.3%     -  396s
+     0     0 95458.5074    0 1360 301378.045 95458.5074  68.3%     -  399s
+     0     0 95458.5074    0 1360 301378.045 95458.5074  68.3%     -  407s
+H    0     0                    300375.43527 95458.5074  68.2%     -  460s
+     0     2 95458.5074    0 1360 300375.435 95458.5074  68.2%     -  461s
+     1     4 96040.0752    1 1362 300375.435 95458.5074  68.2%  8550  492s
+     3     8 96135.0252    2 1263 300375.435 96040.0752  68.0% 12411  515s
+     7    16 96704.7660    3 1354 300375.435 96086.9206  68.0% 10194  565s
+    15    24 99629.5555    4 1266 300375.435 96654.3468  67.8% 11078  576s
+    23    32 97459.1413    4 1216 300375.435 96654.3468  67.8%  8654  585s
+H   31    40                    300375.42778 97459.1413  67.6%  7077  598s
+H   34    40                    299367.08111 97459.1413  67.4%  6605  598s
+    39    48 97752.8540    6 1196 299367.081 97459.1413  67.4%  5972  605s
+    47    60 97753.2137    7 1192 299367.081 97459.1413  67.4%  5331  613s
+    59    69 97892.8765    9 1307 299367.081 97459.1413  67.4%  4469  623s
+H   68    77                    299367.08100 97459.1413  67.4%  3989  650s
+H   69    77                    299367.03662 97459.1413  67.4%  3955  650s
+    92   109 98081.9239   13 1263 299367.037 97459.1413  67.4%  3188  657s
+   108   118 98210.1599   14 1229 299367.037 97459.1413  67.4%  2868  673s
+H  109   118                    299361.37637 97459.1413  67.4%  2842  673s
+H  112   118                    296360.94508 97459.1413  67.1%  2781  673s
+H  114   118                    287059.82071 97459.1413  66.0%  2750  673s
+   117   137 98263.8892   15 1222 287059.821 97459.1413  66.0%  2712  678s
+   136   160 98520.2194   18 1158 287059.821 97459.1413  66.0%  2466  681s
+   159   186 98905.3757   18 1071 287059.821 97459.1413  66.0%  2198  686s
+   185   211 98907.4003   19 1096 287059.821 97459.1413  66.0%  1974  690s
+   210   241 99293.2165   22  978 287059.821 97459.1413  66.0%  1812  696s
+   240   271 99763.2304   24  974 287059.821 97459.1413  66.0%  1666  702s
+   270   309 100312.256   25  967 287059.821 97459.1413  66.0%  1556  707s
+   308   350 101844.383   27  889 287059.821 97459.1413  66.0%  1417  713s
+   349   391 109140.520   31  885 287059.821 97459.1413  66.0%  1309  717s
+   390   433 102004.623   31  933 287059.821 97459.1413  66.0%  1209  723s
+   432   468 102827.200   34  819 287059.821 97459.1413  66.0%  1145  731s
+   467   519 104317.367   38  781 287059.821 97459.1413  66.0%  1097  736s
+H  518   527                    284041.28652 97459.1413  65.7%  1027  768s
+H  519   527                    280541.91807 97459.1413  65.3%  1025  768s
+H  523   527                    274044.45095 97459.1413  64.4%  1022  768s
+   526   580 105461.618   40  767 274044.451 97459.1413  64.4%  1018  776s
+   583   631 109885.257   42  766 274044.451 97459.1413  64.4%   961  785s
+   642   688 110443.541   45  700 274044.451 97459.1413  64.4%   905  801s
+   707   704 112297.236   55  658 274044.451 97459.1413  64.4%   885  812s
+   725   781 115893.360   62  607 274044.451 97459.1413  64.4%   873  818s
+   804   884 120988.566   75  604 274044.451 97459.1413  64.4%   822  826s
+   909   981 124007.549   90  549 274044.451 97459.1413  64.4%   758  833s
+  1008  1091 125997.247  102  547 274044.451 97459.1413  64.4%   712  839s
+  1124  1215 infeasible  120      274044.451 97459.1413  64.4%   664  845s
+  1256  1340 132011.093  140  496 274044.451 97459.1413  64.4%   619  850s
+  1401  1481 141134.188  158  471 274044.451 97459.1413  64.4%   573  856s
+  1550  1525 146623.795  183  441 274044.451 97459.1413  64.4%   536  876s
+H 1590  1525                    274044.45092 97459.1413  64.4%   525  876s
+H 1591  1525                    274044.45083 97459.1413  64.4%   525  876s
+H 1592  1525                    270057.12580 97459.1413  63.9%   525  876s
+  1596  1705 152128.754  208  424 270057.126 97459.1413  63.9%   525  882s
+  1800  1888 159635.312  230  402 270057.126 97459.1413  63.9%   481  887s
+  1997  2087 165642.906  258  386 270057.126 97459.1413  63.9%   449  892s
+  2245  2315 173149.934  285  362 270057.126 97459.1413  63.9%   411  897s
+  2523  2474 181745.318  320  343 270057.126 97459.1413  63.9%   376  900s
+
+Cutting planes:
+  Learned: 1
+  Cover: 423
+  Implied bound: 2265
+  Clique: 4695
+  MIR: 327
+  StrongCG: 16
+  Flow cover: 312
+  GUB cover: 162
+  Zero half: 174
+  RLT: 376
+  Relax-and-lift: 284
+  BQP: 3
+
+Explored 2718 nodes (1079148 simplex iterations) in 900.04 seconds (3066.71 work units)
+Thread count was 8 (of 8 available processors)
+
+Solution count 10: 270057 274044 280542 ... 300375
+
+Time limit reached
+Best objective 2.700571258033e+05, best bound 9.745914132388e+04, gap 63.9117%
diff --git a/data/912-NoRelHeurWork60-glass4-0.log b/tests/assets/mip_norel.log
similarity index 100%
rename from data/912-NoRelHeurWork60-glass4-0.log
rename to tests/assets/mip_norel.log
diff --git a/tests/assets/nonconvex.log b/tests/assets/nonconvex.log
new file mode 100644
index 0000000..504dd30
--- /dev/null
+++ b/tests/assets/nonconvex.log
@@ -0,0 +1,67 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Tue Jan 25 17:46:06 2022
+
+Set parameter TimeLimit to value 900
+Set parameter LogFile to value "nonconvex.log"
+Set parameter NonConvex to value 2
+Using license file /Users/x/gurobi.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read LP format model from file nonconvex.lp.bz2
+Reading time = 0.01 seconds
+nonconvex.lp: 3 rows, 21 columns, 40 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 3 rows, 21 columns and 40 nonzeros
+Model fingerprint: 0x549a449e
+Model has 200 quadratic objective terms
+Model has 1 quadratic constraint
+Coefficient statistics:
+  Matrix range     [1e-02, 1e+00]
+  QMatrix range    [1e-02, 2e+00]
+  QLMatrix range   [1e-02, 9e-01]
+  Objective range  [3e-02, 1e+00]
+  QObjective range [2e-02, 4e+00]
+  Bounds range     [1e+00, 1e+00]
+  RHS range        [5e-01, 1e+00]
+  QRHS range       [7e+01, 7e+01]
+Presolve removed 1 rows and 1 columns
+
+Continuous model is non-convex -- solving as a MIP
+
+Presolve removed 1 rows and 1 columns
+Presolve time: 0.00s
+Presolved: 581 rows, 230 columns, 1603 nonzeros
+Presolved model has 7 quadratic constraint(s)
+Presolved model has 202 bilinear constraint(s)
+Variable types: 230 continuous, 0 integer (0 binary)
+
+Root relaxation: objective -2.997943e+01, 319 iterations, 0.01 seconds (0.00 work units)
+
+    Nodes    |    Current Node    |     Objective Bounds      |     Work
+ Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time
+
+     0     0  -29.97943    0  197          -  -29.97943      -     -    0s
+H    0     0                     -16.7706131  -29.97943  78.8%     -    0s
+     0     0  -20.66554    0  175  -16.77061  -20.66554  23.2%     -    0s
+     0     0  -20.61108    0  176  -16.77061  -20.61108  22.9%     -    0s
+     0     0  -17.61135    0  178  -16.77061  -17.61135  5.01%     -    0s
+     0     0  -17.46459    0  176  -16.77061  -17.46459  4.14%     -    0s
+     0     0  -17.07358    0  180  -16.77061  -17.07358  1.81%     -    0s
+     0     0  -17.05414    0  175  -16.77061  -17.05414  1.69%     -    0s
+     0     0  -16.97372    0  139  -16.77061  -16.97372  1.21%     -    0s
+     0     2  -16.97372    0  139  -16.77061  -16.97372  1.21%     -    0s
+
+Cutting planes:
+  RLT: 82
+  BQP: 48
+  PSD: 17
+
+Explored 45 nodes (9392 simplex iterations) in 0.28 seconds (0.44 work units)
+Thread count was 8 (of 8 available processors)
+
+Solution count 1: -16.7706
+
+Optimal solution found (tolerance 1.00e-04)
+Best objective -1.677061298141e+01, best bound -1.677109891195e+01, gap 0.0029%
diff --git a/tests/assets/qp.log b/tests/assets/qp.log
new file mode 100644
index 0000000..e44b894
--- /dev/null
+++ b/tests/assets/qp.log
@@ -0,0 +1,322 @@
+
+Gurobi 9.5.0 (mac64[arm], gurobi_cl) logging started Tue Jan 25 18:35:30 2022
+
+Set parameter LogFile to value "qp.log"
+Using license file /Users/x/gurobi.lic
+
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read MPS format model from file qp.mps.bz2
+Reading time = 0.14 seconds
+qp.mps: 27894 rows, 20393 columns, 86264 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+Optimize a model with 27894 rows, 20393 columns and 86264 nonzeros
+Model fingerprint: 0xfa8e74cc
+Model has 8678 quadratic constraints
+Coefficient statistics:
+  Matrix range     [3e-02, 1e+04]
+  QMatrix range    [1e+00, 1e+00]
+  Objective range  [5e+03, 2e+04]
+  Bounds range     [1e-03, 3e+01]
+  RHS range        [9e-04, 4e+00]
+  QRHS range       [8e-03, 3e+02]
+Presolve removed 1230 rows and 1223 columns
+Presolve time: 0.03s
+Presolved: 69092 rows, 30012 columns, 137635 nonzeros
+Presolved model has 7931 second-order cone constraints
+Ordering time: 0.24s
+
+Barrier statistics:
+ AA' NZ     : 6.526e+05
+ Factor NZ  : 1.294e+06 (roughly 50 MB of memory)
+ Factor Ops : 5.520e+07 (less than 1 second per iteration)
+ Threads    : 8
+
+                  Objective                Residual
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+   0   7.22971464e+06 -1.88940038e+07  1.64e+01 8.03e+03  3.62e+04     0s
+   1   1.94916078e+06 -2.28981504e+08  3.72e+00 9.37e+02  9.23e+03     0s
+   2   1.20530165e+06 -1.19060852e+08  1.70e+00 1.03e+02  3.22e+03     0s
+   3   7.19738456e+05 -4.90751392e+07  4.67e-01 2.49e+01  8.96e+02     0s
+   4   6.30643852e+05 -1.84572479e+07  2.24e-01 5.50e+00  3.10e+02     0s
+   5   5.90365443e+05 -1.07651514e+07  1.07e-01 2.98e+00  1.66e+02     0s
+   6   5.75646622e+05 -6.34258480e+06  5.98e-02 1.69e+00  9.66e+01     0s
+   7   5.69719809e+05 -3.21311147e+06  3.96e-02 8.73e-01  5.23e+01     0s
+   8   5.66023840e+05 -1.88786310e+06  2.69e-02 5.52e-01  3.35e+01     0s
+   9   5.63346908e+05 -4.05380741e+05  1.81e-02 2.07e-01  1.38e+01     1s
+  10   5.61895994e+05 -1.14799213e+05  1.30e-02 1.45e-01  9.68e+00     1s
+  11   5.61083762e+05  1.78916045e+05  1.01e-02 8.41e-02  5.94e+00     1s
+  12   5.60459897e+05  2.60205822e+05  7.80e-03 6.81e-02  4.74e+00     1s
+  13   5.60313654e+05  3.22063242e+05  7.26e-03 5.67e-02  4.04e+00     1s
+  14   5.60064635e+05  3.72630776e+05  6.24e-03 4.83e-02  3.43e+00     1s
+  15   5.59947401e+05  4.30035496e+05  5.77e-03 4.01e-02  2.93e+00     1s
+  16   5.59865073e+05  4.58249880e+05  5.40e-03 3.71e-02  2.71e+00     1s
+  17   5.59827884e+05  4.87062091e+05  5.19e-03 3.47e-02  2.57e+00     1s
+  18   5.59896070e+05  5.14913384e+05  4.96e-03 3.31e-02  2.44e+00     1s
+  19   5.59894219e+05  5.48094411e+05  4.78e-03 3.16e-02  2.34e+00     1s
+  20   5.59925606e+05  6.16492784e+05  4.64e-03 2.97e-02  2.25e+00     1s
+  21   5.60032829e+05  6.39511373e+05  4.54e-03 2.93e-02  2.19e+00     1s
+  22   5.60215705e+05  7.15688098e+05  4.45e-03 2.85e-02  2.14e+00     1s
+  23   5.60936004e+05  7.63681749e+05  4.38e-03 2.82e-02  2.10e+00     1s
+  24   5.64791531e+05  7.58547889e+05  4.34e-03 2.80e-02  2.10e+00     1s
+  25   5.71082856e+05  7.72894843e+05  4.26e-03 2.77e-02  2.06e+00     1s
+  26   5.74171507e+05  7.78323023e+05  4.22e-03 2.75e-02  2.05e+00     1s
+  27   5.76986715e+05  7.83581675e+05  4.19e-03 2.73e-02  2.04e+00     1s
+  28   5.81765959e+05  7.93630675e+05  4.13e-03 2.69e-02  2.01e+00     1s
+  29   5.88193063e+05  8.04916077e+05  4.05e-03 2.65e-02  1.97e+00     1s
+  30   5.90285804e+05  8.14360598e+05  4.03e-03 2.61e-02  1.95e+00     1s
+  31   5.91178703e+05  8.19438427e+05  4.02e-03 2.60e-02  1.94e+00     1s
+  32   5.94275689e+05  8.32287528e+05  3.98e-03 2.57e-02  1.92e+00     1s
+  33   5.95501780e+05  8.40847111e+05  3.97e-03 2.55e-02  1.90e+00     1s
+  34   5.95199204e+05  8.81927390e+05  3.96e-03 2.54e-02  1.92e+00     1s
+  35   5.96917155e+05  9.61230637e+05  3.90e-03 2.60e-02  1.87e+00     1s
+  36   6.02663262e+05  9.54237915e+05  3.87e-03 2.63e-02  1.88e+00     1s
+  37   6.04647870e+05  9.87068120e+05  3.85e-03 2.67e-02  1.86e+00     1s
+  38   6.16712488e+05  1.00587233e+06  3.77e-03 2.69e-02  1.83e+00     1s
+  39   6.24596117e+05  1.02709648e+06  3.71e-03 2.66e-02  1.77e+00     1s
+  40   6.34977310e+05  1.03222070e+06  3.63e-03 2.65e-02  1.74e+00     1s
+  41   6.43228780e+05  1.03584167e+06  3.58e-03 2.65e-02  1.74e+00     1s
+  42   6.49587476e+05  1.04796267e+06  3.53e-03 2.64e-02  1.70e+00     2s
+  43   6.50742415e+05  1.05736591e+06  3.53e-03 2.63e-02  1.70e+00     2s
+  44   6.60675954e+05  1.05074478e+06  3.46e-03 2.63e-02  1.69e+00     2s
+  45   6.66865243e+05  1.06941247e+06  3.42e-03 2.62e-02  1.66e+00     2s
+  46   6.71615741e+05  1.07658318e+06  3.39e-03 2.62e-02  1.64e+00     2s
+  47   6.74210218e+05  1.07956687e+06  3.37e-03 2.62e-02  1.64e+00     2s
+  48   6.75657076e+05  1.09509183e+06  3.36e-03 2.63e-02  1.62e+00     2s
+  49   6.78647395e+05  1.10177129e+06  3.34e-03 2.64e-02  1.61e+00     2s
+  50   6.82011886e+05  1.11053952e+06  3.32e-03 2.66e-02  1.60e+00     2s
+  51   6.91013692e+05  1.11479552e+06  3.26e-03 2.67e-02  1.58e+00     2s
+  52   7.00105399e+05  1.12423856e+06  3.21e-03 2.71e-02  1.56e+00     2s
+  53   7.06968219e+05  1.14076283e+06  3.17e-03 2.75e-02  1.51e+00     2s
+  54   7.13887655e+05  1.14438678e+06  3.13e-03 2.77e-02  1.49e+00     2s
+  55   7.17886754e+05  1.14862543e+06  3.10e-03 2.79e-02  1.48e+00     2s
+  56   7.24998447e+05  1.15542702e+06  3.06e-03 2.82e-02  1.46e+00     2s
+  57   7.29735512e+05  1.16282121e+06  3.03e-03 2.84e-02  1.44e+00     2s
+  58   7.34892147e+05  1.16744413e+06  3.00e-03 2.83e-02  1.44e+00     2s
+  59   7.40427527e+05  1.17496511e+06  2.97e-03 2.80e-02  1.42e+00     2s
+  60   7.53577526e+05  1.18019304e+06  2.90e-03 2.75e-02  1.40e+00     2s
+  61   7.59528165e+05  1.18869676e+06  2.87e-03 2.78e-02  1.39e+00     2s
+  62   7.71686401e+05  1.20418658e+06  2.81e-03 2.82e-02  1.33e+00     2s
+  63   7.80655226e+05  1.21240486e+06  2.76e-03 3.06e-02  1.30e+00     2s
+  64   7.86555312e+05  1.21737608e+06  2.73e-03 3.27e-02  1.28e+00     2s
+  65   7.92640762e+05  1.22524222e+06  2.70e-03 3.51e-02  1.26e+00     2s
+  66   7.98144631e+05  1.23097879e+06  2.67e-03 3.57e-02  1.24e+00     3s
+  67   8.05985786e+05  1.23736365e+06  2.63e-03 3.52e-02  1.22e+00     3s
+  68   8.09778518e+05  1.24460295e+06  2.61e-03 3.57e-02  1.21e+00     3s
+  69   8.12323488e+05  1.24711139e+06  2.59e-03 3.59e-02  1.20e+00     3s
+  70   8.16588020e+05  1.25291012e+06  2.57e-03 3.70e-02  1.18e+00     3s
+  71   8.19780809e+05  1.26124446e+06  2.56e-03 3.77e-02  1.17e+00     3s
+  72   8.21708921e+05  1.27349692e+06  2.55e-03 3.74e-02  1.17e+00     3s
+  73   8.22367870e+05  1.26975377e+06  2.54e-03 3.74e-02  1.17e+00     3s
+  74   8.27713460e+05  1.27639652e+06  2.52e-03 3.71e-02  1.16e+00     3s
+  75   8.39125063e+05  1.28493095e+06  2.46e-03 3.63e-02  1.13e+00     3s
+  76   8.46188660e+05  1.29457929e+06  2.43e-03 3.88e-02  1.11e+00     3s
+  77   8.50021914e+05  1.30322612e+06  2.41e-03 3.83e-02  1.09e+00     3s
+  78   8.57779664e+05  1.30176560e+06  2.38e-03 3.81e-02  1.09e+00     3s
+  79   8.62507716e+05  1.31434352e+06  2.36e-03 3.75e-02  1.07e+00     3s
+  80   8.65159747e+05  1.31989283e+06  2.34e-03 3.71e-02  1.06e+00     3s
+  81   8.65435737e+05  1.33278100e+06  2.34e-03 3.70e-02  1.06e+00     3s
+  82   8.69588700e+05  1.32213349e+06  2.33e-03 3.69e-02  1.06e+00     3s
+  83   8.75752595e+05  1.32641896e+06  2.30e-03 3.68e-02  1.06e+00     3s
+  84   8.83403963e+05  1.34767218e+06  2.27e-03 3.61e-02  1.04e+00     4s
+  85   8.91672695e+05  1.35845292e+06  2.23e-03 3.50e-02  1.01e+00     4s
+  86   8.93958941e+05  1.37522153e+06  2.22e-03 3.41e-02  9.86e-01     4s
+  87   9.04665189e+05  1.38471126e+06  2.20e-03 3.40e-02  1.05e+00     4s
+  88   9.13735207e+05  1.39059525e+06  2.16e-03 3.36e-02  1.03e+00     4s
+  89   9.24187737e+05  1.39759013e+06  2.12e-03 3.29e-02  1.01e+00     4s
+  90   9.30732715e+05  1.40359842e+06  2.09e-03 3.23e-02  9.94e-01     4s
+  91   9.33212715e+05  1.41179145e+06  2.08e-03 3.20e-02  9.85e-01     4s
+  92   9.35460164e+05  1.41684738e+06  2.08e-03 3.18e-02  9.81e-01     4s
+  93   9.36535555e+05  1.43470406e+06  2.07e-03 3.16e-02  9.80e-01     4s
+  94   9.41053281e+05  1.44136314e+06  2.05e-03 3.14e-02  9.73e-01     4s
+  95   9.45139146e+05  1.45135111e+06  2.04e-03 3.11e-02  9.65e-01     4s
+  96   9.50824615e+05  1.46089740e+06  2.02e-03 3.09e-02  9.56e-01     4s
+  97   9.56282130e+05  1.47614767e+06  2.00e-03 3.04e-02  9.50e-01     4s
+  98   9.59802777e+05  1.49058479e+06  1.99e-03 2.95e-02  9.32e-01     4s
+  99   9.68327581e+05  1.49593226e+06  1.96e-03 2.91e-02  9.19e-01     4s
+ 100   9.74458901e+05  1.50035450e+06  1.94e-03 2.87e-02  9.06e-01     4s
+ 101   9.81107018e+05  1.50291943e+06  1.92e-03 2.84e-02  8.97e-01     4s
+ 102   9.86718886e+05  1.50499369e+06  1.90e-03 2.82e-02  8.89e-01     5s
+ 103   9.92162187e+05  1.50668913e+06  1.88e-03 2.80e-02  8.83e-01     5s
+ 104   9.97658981e+05  1.50801401e+06  1.86e-03 2.79e-02  8.78e-01     5s
+ 105   1.00381998e+06  1.50913386e+06  1.84e-03 2.78e-02  8.73e-01     5s
+ 106   1.01061999e+06  1.51035971e+06  1.82e-03 2.77e-02  8.68e-01     5s
+ 107   1.01352838e+06  1.51293547e+06  1.81e-03 2.75e-02  8.64e-01     5s
+ 108   1.01768886e+06  1.51582731e+06  1.79e-03 2.74e-02  8.61e-01     5s
+ 109   1.02381462e+06  1.51725790e+06  1.78e-03 2.73e-02  8.57e-01     5s
+ 110   1.02872587e+06  1.51938698e+06  1.76e-03 2.73e-02  8.59e-01     5s
+ 111   1.04104328e+06  1.52814909e+06  1.72e-03 2.64e-02  8.35e-01     5s
+ 112   1.04513679e+06  1.53436003e+06  1.71e-03 2.58e-02  8.17e-01     5s
+ 113   1.05775203e+06  1.54117832e+06  1.67e-03 2.51e-02  8.00e-01     5s
+ 114   1.06719042e+06  1.54392670e+06  1.64e-03 2.49e-02  7.91e-01     5s
+ 115   1.07135192e+06  1.54620175e+06  1.63e-03 2.49e-02  7.91e-01     5s
+ 116   1.07273542e+06  1.56442423e+06  1.62e-03 2.40e-02  7.68e-01     5s
+ 117   1.07503494e+06  1.56302350e+06  1.62e-03 2.39e-02  7.66e-01     5s
+ 118   1.07773596e+06  1.56684917e+06  1.61e-03 2.38e-02  7.62e-01     5s
+ 119   1.08083534e+06  1.56904011e+06  1.60e-03 2.37e-02  7.58e-01     5s
+ 120   1.08433395e+06  1.57456375e+06  1.59e-03 2.37e-02  7.62e-01     6s
+ 121   1.09045123e+06  1.57853030e+06  1.57e-03 2.27e-02  7.42e-01     6s
+ 122   1.09474475e+06  1.58906185e+06  1.56e-03 2.22e-02  7.34e-01     6s
+ 123   1.10700147e+06  1.59113557e+06  1.52e-03 2.18e-02  7.25e-01     6s
+ 124   1.12133601e+06  1.59570148e+06  1.48e-03 2.15e-02  7.11e-01     6s
+ 125   1.12503470e+06  1.60264768e+06  1.47e-03 2.12e-02  7.02e-01     6s
+ 126   1.12681694e+06  1.60770983e+06  1.47e-03 2.10e-02  6.98e-01     6s
+ 127   1.13352262e+06  1.61101559e+06  1.45e-03 2.09e-02  6.91e-01     6s
+ 128   1.13891385e+06  1.61576080e+06  1.43e-03 2.07e-02  6.84e-01     6s
+ 129   1.14247709e+06  1.62104906e+06  1.42e-03 2.05e-02  6.79e-01     6s
+ 130   1.15256864e+06  1.62175537e+06  1.39e-03 2.03e-02  6.70e-01     6s
+ 131   1.15601114e+06  1.62898490e+06  1.39e-03 1.99e-02  6.59e-01     6s
+ 132   1.15801812e+06  1.63423989e+06  1.38e-03 1.98e-02  6.57e-01     6s
+ 133   1.16460668e+06  1.63137885e+06  1.36e-03 1.98e-02  6.54e-01     6s
+ 134   1.16968091e+06  1.63778066e+06  1.35e-03 1.96e-02  6.48e-01     6s
+ 135   1.17285032e+06  1.64282419e+06  1.34e-03 1.94e-02  6.43e-01     6s
+ 136   1.17335249e+06  1.65746577e+06  1.34e-03 1.92e-02  6.42e-01     7s
+ 137   1.17677605e+06  1.64948876e+06  1.34e-03 1.92e-02  6.67e-01     7s
+ 138   1.18288640e+06  1.66922630e+06  1.32e-03 1.86e-02  6.50e-01     7s
+ 139   1.18673413e+06  1.67780156e+06  1.31e-03 1.80e-02  6.37e-01     7s
+ 140   1.18830539e+06  1.68630738e+06  1.30e-03 1.79e-02  6.34e-01     7s
+ 141   1.19038833e+06  1.69411188e+06  1.30e-03 1.77e-02  6.31e-01     7s
+ 142   1.19121057e+06  1.70203486e+06  1.30e-03 1.77e-02  6.30e-01     7s
+ 143   1.19268067e+06  1.70233529e+06  1.29e-03 1.76e-02  6.27e-01     7s
+ 144   1.19896829e+06  1.70833043e+06  1.28e-03 1.75e-02  6.21e-01     7s
+ 145   1.21376326e+06  1.71202269e+06  1.24e-03 1.72e-02  6.14e-01     7s
+ 146   1.22050027e+06  1.71506777e+06  1.23e-03 1.62e-02  5.91e-01     7s
+ 147   1.23088725e+06  1.72524506e+06  1.20e-03 1.58e-02  5.81e-01     7s
+ 148   1.23701005e+06  1.72843819e+06  1.19e-03 1.56e-02  5.76e-01     7s
+ 149   1.24305943e+06  1.73459846e+06  1.18e-03 1.51e-02  5.61e-01     7s
+ 150   1.24973612e+06  1.73771328e+06  1.16e-03 1.49e-02  5.54e-01     7s
+ 151   1.25353176e+06  1.73893832e+06  1.15e-03 1.48e-02  5.49e-01     8s
+ 152   1.25915761e+06  1.73981476e+06  1.14e-03 1.47e-02  5.45e-01     8s
+ 153   1.26429389e+06  1.74060910e+06  1.13e-03 1.46e-02  5.42e-01     8s
+ 154   1.27038687e+06  1.74128040e+06  1.12e-03 1.46e-02  5.38e-01     8s
+ 155   1.27335734e+06  1.74302298e+06  1.11e-03 1.44e-02  5.33e-01     8s
+ 156   1.27957332e+06  1.74413143e+06  1.10e-03 1.43e-02  5.28e-01     8s
+ 157   1.28441639e+06  1.74513102e+06  1.08e-03 1.42e-02  5.24e-01     8s
+ 158   1.29070109e+06  1.74590342e+06  1.07e-03 1.41e-02  5.20e-01     8s
+ 159   1.29660770e+06  1.74669523e+06  1.06e-03 1.41e-02  5.16e-01     8s
+ 160   1.30445982e+06  1.74740854e+06  1.04e-03 1.40e-02  5.12e-01     8s
+ 161   1.30901707e+06  1.74834487e+06  1.03e-03 1.39e-02  5.09e-01     8s
+ 162   1.31065161e+06  1.75068210e+06  1.03e-03 1.38e-02  5.04e-01     8s
+ 163   1.31829278e+06  1.75192688e+06  1.01e-03 1.37e-02  5.00e-01     8s
+ 164   1.32159806e+06  1.75541921e+06  1.00e-03 1.34e-02  4.91e-01     8s
+ 165   1.32607805e+06  1.75780133e+06  9.93e-04 1.31e-02  4.83e-01     8s
+ 166   1.33170596e+06  1.76323633e+06  9.81e-04 1.26e-02  4.69e-01     8s
+ 167   1.33902113e+06  1.76153730e+06  9.65e-04 1.24e-02  4.65e-01     8s
+ 168   1.34311140e+06  1.76792939e+06  9.56e-04 1.20e-02  4.50e-01     8s
+ 169   1.35546271e+06  1.77012707e+06  9.30e-04 1.17e-02  4.40e-01     8s
+ 170   1.37771340e+06  1.77265193e+06  8.84e-04 1.13e-02  4.29e-01     9s
+ 171   1.40237385e+06  1.77604637e+06  8.33e-04 1.02e-02  4.07e-01     9s
+ 172   1.41015362e+06  1.78013044e+06  8.17e-04 9.98e-03  3.99e-01     9s
+ 173   1.43115816e+06  1.78291515e+06  7.74e-04 9.52e-03  3.85e-01     9s
+ 174   1.43645177e+06  1.79032218e+06  7.64e-04 8.78e-03  3.66e-01     9s
+ 175   1.44874165e+06  1.79352418e+06  7.39e-04 8.32e-03  3.50e-01     9s
+ 176   1.45713860e+06  1.79882106e+06  7.23e-04 8.19e-03  3.48e-01     9s
+ 177   1.49111817e+06  1.80113724e+06  6.56e-04 7.86e-03  3.37e-01     9s
+ 178   1.52253970e+06  1.80392206e+06  5.94e-04 7.03e-03  3.14e-01     9s
+ 179   1.54966314e+06  1.80617010e+06  5.41e-04 6.57e-03  2.95e-01     9s
+ 180   1.59221569e+06  1.81214755e+06  4.59e-04 5.23e-03  2.45e-01     9s
+ 181   1.59662344e+06  1.81930102e+06  4.50e-04 4.08e-03  2.15e-01     9s
+ 182   1.59786467e+06  1.82352709e+06  4.48e-04 4.08e-03  2.14e-01     9s
+ 183   1.62008995e+06  1.81787787e+06  4.06e-04 3.89e-03  2.08e-01     9s
+ 184   1.62684091e+06  1.82392821e+06  3.94e-04 3.56e-03  1.97e-01     9s
+ 185   1.64650816e+06  1.82598976e+06  3.57e-04 3.24e-03  1.82e-01     9s
+ 186   1.65411277e+06  1.82802050e+06  3.43e-04 2.90e-03  1.67e-01     9s
+ 187   1.67777634e+06  1.82940479e+06  2.98e-04 2.61e-03  1.51e-01     9s
+ 188   1.68020181e+06  1.82996831e+06  2.94e-04 2.53e-03  1.47e-01     9s
+ 189   1.68105477e+06  1.83389936e+06  2.92e-04 2.51e-03  1.46e-01    10s
+ 190   1.69518077e+06  1.83057777e+06  2.67e-04 2.62e-03  1.48e-01    10s
+ 191   1.70460693e+06  1.83342267e+06  2.49e-04 2.58e-03  1.28e-01    10s
+ 192   1.71143788e+06  1.83582884e+06  2.37e-04 2.47e-03  1.19e-01    10s
+ 193   1.72169797e+06  1.83671820e+06  2.19e-04 2.39e-03  1.12e-01    10s
+ 194   1.73007894e+06  1.83841115e+06  2.04e-04 2.02e-03  9.79e-02    10s
+ 195   1.73918773e+06  1.83990865e+06  1.88e-04 1.64e-03  8.36e-02    10s
+ 196   1.74536371e+06  1.84046123e+06  1.76e-04 1.50e-03  7.75e-02    10s
+ 197   1.74716028e+06  1.84132557e+06  1.73e-04 1.47e-03  7.31e-02    10s
+ 198   1.75593434e+06  1.84212505e+06  1.58e-04 1.36e-03  6.65e-02    10s
+ 199   1.76147208e+06  1.84247508e+06  1.48e-04 1.29e-03  6.30e-02    10s
+ 200   1.76339751e+06  1.84266457e+06  1.44e-04 1.25e-03  6.12e-02    10s
+ 201   1.76576778e+06  1.84287172e+06  1.40e-04 1.23e-03  5.98e-02    10s
+ 202   1.76785882e+06  1.84348998e+06  1.37e-04 1.09e-03  5.71e-02    10s
+ 203   1.77525316e+06  1.84474671e+06  1.24e-04 9.26e-04  5.28e-02    10s
+ 204   1.78693939e+06  1.84506963e+06  1.03e-04 8.00e-04  4.77e-02    10s
+ 205   1.79468383e+06  1.84479223e+06  9.01e-05 8.09e-04  4.37e-02    11s
+ 206   1.79954235e+06  1.84520923e+06  8.17e-05 6.87e-04  4.01e-02    11s
+ 207   1.80178992e+06  1.84526088e+06  7.79e-05 6.71e-04  3.90e-02    11s
+ 208   1.80566370e+06  1.84566548e+06  7.13e-05 6.23e-04  3.61e-02    11s
+ 209   1.80852012e+06  1.84622385e+06  6.65e-05 6.39e-04  3.12e-02    11s
+ 210   1.81112661e+06  1.84629297e+06  6.20e-05 6.51e-04  2.99e-02    11s
+ 211   1.81493571e+06  1.84651924e+06  5.56e-05 8.65e-04  2.69e-02    11s
+ 212   1.81743637e+06  1.84679319e+06  5.13e-05 7.98e-04  2.38e-02    11s
+ 213   1.81908349e+06  1.84699855e+06  4.86e-05 1.20e-03  2.17e-02    11s
+ 214   1.82146867e+06  1.84718307e+06  4.46e-05 1.30e-03  1.95e-02    12s
+ 215   1.82272960e+06  1.84735670e+06  4.24e-05 1.31e-03  1.78e-02    12s
+ 216   1.82440354e+06  1.84748493e+06  3.96e-05 1.41e-03  1.62e-02    12s
+ 217   1.82548651e+06  1.84753298e+06  3.78e-05 1.50e-03  1.55e-02    12s
+ 218   1.82664482e+06  1.84765733e+06  3.59e-05 3.48e-03  1.42e-02    12s
+ 219   1.82698055e+06  1.84768305e+06  3.53e-05 3.72e-03  1.39e-02    12s
+ 220   1.82880335e+06  1.84776023e+06  3.23e-05 2.38e-03  1.27e-02    12s
+ 221   1.82914998e+06  1.84787397e+06  3.17e-05 1.75e-03  1.18e-02    12s
+ 222   1.83007035e+06  1.84793267e+06  3.02e-05 1.69e-03  1.11e-02    12s
+ 223   1.83038525e+06  1.84800630e+06  2.96e-05 1.91e-03  1.06e-02    13s
+ 224   1.83119418e+06  1.84802504e+06  2.83e-05 1.84e-03  1.02e-02    13s
+ 225   1.83309821e+06  1.84810657e+06  2.52e-05 2.33e-03  9.28e-03    13s
+ 226   1.83535602e+06  1.84818023e+06  2.15e-05 2.91e-03  7.94e-03    13s
+ 227   1.83637796e+06  1.84824627e+06  1.98e-05 3.03e-03  7.12e-03    13s
+ 228   1.83649888e+06  1.84830236e+06  1.96e-05 2.27e-03  6.86e-03    13s
+ 229   1.83697919e+06  1.84833179e+06  1.88e-05 2.23e-03  6.73e-03    13s
+ 230   1.83715755e+06  1.84837406e+06  1.85e-05 1.97e-03  6.54e-03    14s
+ 231   1.83797367e+06  1.84840358e+06  1.72e-05 2.03e-03  6.05e-03    14s
+ 232   1.83831517e+06  1.84843425e+06  1.66e-05 2.48e-03  5.81e-03    14s
+ 233   1.83871724e+06  1.84847858e+06  1.60e-05 2.33e-03  5.51e-03    14s
+ 234   1.83903664e+06  1.84851325e+06  1.55e-05 3.10e-03  5.28e-03    14s
+ 235   1.83965988e+06  1.84853655e+06  1.45e-05 2.49e+01  4.98e-03    14s
+ 236   1.83976187e+06  1.84853254e+06  1.43e-05 2.67e+01  4.93e-03    15s
+ 237   1.84047411e+06  1.84857726e+06  1.32e-05 2.43e+01  4.66e-03    15s
+ 238   1.84112483e+06  1.84860290e+06  1.21e-05 2.27e+01  4.38e-03    15s
+ 239   1.84120997e+06  1.84860326e+06  1.20e-05 2.57e+01  4.32e-03    15s
+ 240   1.84169583e+06  1.84860996e+06  1.12e-05 3.47e+01  4.06e-03    15s
+ 241   1.84246515e+06  1.84862163e+06  1.07e-05 5.14e+01  3.86e-03    15s
+ 242   1.84258509e+06  1.84862800e+06  1.14e-05 6.72e+01  3.74e-03    15s
+ 243   1.84313229e+06  1.84864754e+06  1.03e-05 6.65e+01  3.49e-03    15s
+ 244   1.84344587e+06  1.84866357e+06  1.26e-05 9.43e+01  3.45e-03    15s
+ 245   1.84375386e+06  1.84871995e+06  1.21e-05 7.69e+01  3.12e-03    15s
+ 246   1.84419626e+06  1.84872779e+06  1.19e-05 9.44e+01  2.93e-03    15s
+ 247   1.84490246e+06  1.84874215e+06  1.45e-05 1.13e+02  2.59e-03    16s
+ 248   1.84512828e+06  1.84876240e+06  1.37e-05 1.03e+02  2.43e-03    16s
+ 249   1.84537902e+06  1.84876331e+06  1.27e-05 9.99e+01  2.30e-03    16s
+ 250   1.84548075e+06  1.84876473e+06  1.56e-05 1.05e+02  2.24e-03    16s
+ 251   1.84561753e+06  1.84877031e+06  2.86e-05 1.16e+02  2.15e-03    16s
+ 252   1.84565516e+06  1.84877374e+06  2.86e-05 1.24e+02  2.12e-03    16s
+ 253   1.84602858e+06  1.84878224e+06  2.89e-05 1.23e+02  1.93e-03    16s
+ 254   1.84611450e+06  1.84878627e+06  2.75e-05 1.54e+02  1.86e-03    16s
+ 255   1.84629858e+06  1.84878399e+06  1.06e-04 1.68e+02  1.88e-03    17s
+ 256   1.84633652e+06  1.84878256e+06  1.06e-04 1.75e+02  1.86e-03    17s
+ 257   1.84642484e+06  1.84878383e+06  1.03e-04 1.97e+02  1.84e-03    17s
+ 258   1.84648650e+06  1.84878736e+06  1.01e-04 2.11e+02  1.90e-03    17s
+ 259   1.84650703e+06  1.84878811e+06  9.97e-05 2.19e+02  1.88e-03    17s
+ 260   1.84663056e+06  1.84879330e+06  9.46e-05 2.59e+02  1.77e-03    17s
+ 261   1.84665829e+06  1.84879348e+06  9.32e-05 2.66e+02  1.75e-03    17s
+ 262   1.84672223e+06  1.84879519e+06  4.29e-04 2.78e+02  1.71e-03    17s
+ 263   1.84673857e+06  1.84879429e+06  4.24e-04 2.85e+02  1.69e-03    17s
+ 264   1.84683573e+06  1.84879615e+06  4.01e-04 3.00e+02  1.61e-03    18s
+ 265   1.84688782e+06  1.84879861e+06  3.91e-04 3.24e+02  1.55e-03    18s
+ 266   1.84692392e+06  1.84879936e+06  3.83e-04 3.30e+02  1.52e-03    18s
+ 267   1.84694259e+06  1.84879958e+06  3.74e-04 3.34e+02  1.49e-03    18s
+ 268   1.84695105e+06  1.84879953e+06  3.73e-04 3.36e+02  1.49e-03    18s
+ 269   1.84705927e+06  1.84880518e+06  3.42e-04 2.81e+02  1.39e-03    18s
+ 270   1.84705041e+06  1.84880524e+06  4.73e-04 2.82e+02  1.39e-03    18s
+ 271   1.84712658e+06  1.84880686e+06  4.52e-04 3.03e+02  1.33e-03    18s
+ 272   1.84713455e+06  1.84880549e+06  4.46e-04 3.14e+02  1.31e-03    18s
+ 273   1.84717726e+06  1.84880755e+06  4.33e-04 3.29e+02  1.27e-03    18s
+ 274   1.84718643e+06  1.84880788e+06  4.30e-04 3.32e+02  1.26e-03    18s
+ 275   1.84722298e+06  1.84880978e+06  4.22e-04 3.42e+02  1.23e-03    19s
+ 276   1.84726938e+06  1.84881207e+06  4.17e-04 3.50e+02  1.22e-03    19s
+ 277   1.84729879e+06  1.84881262e+06  8.28e-04 3.70e+02  1.26e-03    19s
+ 278   1.84742794e+06  1.84881412e+06  7.64e-04 3.83e+02  1.22e-03    19s
+ 279   1.84744571e+06  1.84881444e+06  7.54e-04 3.89e+02  1.21e-03    19s
+ 280   1.84745256e+06  1.84881482e+06  7.50e-04 4.01e+02  1.20e-03    19s
+ 281   1.84745417e+06  1.84881428e+06  7.47e-04 4.03e+02  1.21e-03    19s
+
+Barrier performed 281 iterations in 19.32 seconds (20.83 work units)
+Sub-optimal termination - objective 1.83903664e+06
diff --git a/tests/helpers.py b/tests/helpers.py
index b742abe..b3a9bcf 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,6 +1,7 @@
 import grblogtools as glt
 
+
 def read_single(filename):
-    summary = glt.get_dataframe([f'data/{filename}'])
-    rows = summary.to_dict(orient='records')
+    summary = glt.get_dataframe([f"data/{filename}"])
+    rows = summary.to_dict(orient="records")
     return rows[0]
diff --git a/tests/parsers/__init__.py b/tests/parsers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/parsers/test_barrier.py b/tests/parsers/test_barrier.py
new file mode 100644
index 0000000..9bb06f1
--- /dev/null
+++ b/tests/parsers/test_barrier.py
@@ -0,0 +1,80 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.barrier import BarrierParser
+from grblogtools.parsers.util import parse_block
+
+example_log_barrier = """
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+    0   4.56435085e+07  1.53061018e+04  1.69e+05 8.58e+00  1.59e+03     2s
+    1   3.76722276e+07 -5.41297282e+05  8.07e+04 9.12e+00  8.17e+02     2s
+   17   2.17403572e+02  2.17403571e+02  3.93e-14 7.11e-15  7.71e-13     5s
+
+Barrier solved model in 17 iterations and 4.83 seconds (6.45 work units)
+Optimal objective 2.17403572e+02
+
+Crossover log...
+
+    57249 DPushes remaining with DInf 0.0000000e+00                 8s
+        0 DPushes remaining with DInf 0.0000000e+00                 9s
+
+     9342 PPushes remaining with PInf 1.2118572e-05                 9s
+        0 PPushes remaining with PInf 0.0000000e+00                 9s
+
+   Push phase complete: Pinf 0.0000000e+00, Dinf 1.8540725e-14      9s
+"""
+expected_summary_barrier = {
+    "BarIterCount": 17,
+    "Runtime": 4.83,
+    "PushPhasePInf": 0.0000000e00,
+    "PushPhaseDInf": 1.8540725e-14,
+    "PushPhaseEndTime": 9,
+}
+expected_progress_barrier = [
+    {
+        "Type": "barrier",
+        "Iteration": 0,
+        "Indicator": " ",
+        "PObj": 45643508.5,
+        "DObj": 15306.1018,
+        "PRes": 169000.0,
+        "DRes": 8.58,
+        "Compl": 1590.0,
+        "Time": 2,
+    },
+    {
+        "Type": "barrier",
+        "Iteration": 1,
+        "Indicator": " ",
+        "PObj": 37672227.6,
+        "DObj": -541297.282,
+        "PRes": 80700.0,
+        "DRes": 9.12,
+        "Compl": 817.0,
+        "Time": 2,
+    },
+    {
+        "Type": "barrier",
+        "Iteration": 17,
+        "Indicator": " ",
+        "PObj": 217.403572,
+        "DObj": 217.403571,
+        "PRes": 3.93e-14,
+        "DRes": 7.11e-15,
+        "Compl": 7.71e-13,
+        "Time": 5,
+    },
+]
+
+
+class TestBarrier(TestCase):
+    def setUp(self):
+        self.barrier_parser = BarrierParser()
+
+    def test_get_summary_progress(self):
+        parse_block(self.barrier_parser, example_log_barrier)
+        self.assertEqual(self.barrier_parser.get_summary(), expected_summary_barrier)
+        self.assertEqual(self.barrier_parser.get_progress(), expected_progress_barrier)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_continuous.py b/tests/parsers/test_continuous.py
new file mode 100644
index 0000000..176c34d
--- /dev/null
+++ b/tests/parsers/test_continuous.py
@@ -0,0 +1,162 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.continuous import ContinuousParser
+from grblogtools.parsers.util import parse_block
+
+example_log_barrier_with_simplex = """
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+    0   4.56435085e+07  1.53061018e+04  1.69e+05 8.58e+00  1.59e+03     2s
+    1   3.76722276e+07 -5.41297282e+05  8.07e+04 9.12e+00  8.17e+02     2s
+   17   2.17403572e+02  2.17403571e+02  3.93e-14 7.11e-15  7.71e-13     5s
+
+Barrier solved model in 17 iterations and 4.83 seconds (6.45 work units)
+Optimal objective 2.17403572e+02
+
+Crossover log...
+
+    57249 DPushes remaining with DInf 0.0000000e+00                 8s
+        0 DPushes remaining with DInf 0.0000000e+00                 9s
+
+     9342 PPushes remaining with PInf 1.2118572e-05                 9s
+        0 PPushes remaining with PInf 0.0000000e+00                 9s
+
+   Push phase complete: Pinf 0.0000000e+00, Dinf 1.8540725e-14      9s
+
+Extra simplex iterations after uncrush: 1
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+    51765    2.1740357e+02   0.000000e+00   0.000000e+00      9s
+
+Solved in 51765 iterations and 9.18 seconds (13.22 work units)
+Optimal objective  2.174035714e+02
+"""
+expected_progress_last_entry = {
+    "Type": "simplex",
+    "Iteration": 51765,
+    "Objective": 217.40357,
+    "PInf": 0.0,
+    "DInf": 0.0,
+    "Time": 9,
+}
+
+
+example_log_concurrent = """
+Iter       Primal          Dual         Primal    Dual     Compl     Time
+   0   4.56435085e+07  1.53061018e+04  1.69e+05 8.58e+00  1.59e+03     2s
+
+Barrier performed 7 iterations in 4.02 seconds (4.19 work units)
+Barrier solve interrupted - model solved by another algorithm
+
+
+Solved with dual simplex
+Solved in 69986 iterations and 4.03 seconds (3.91 work units)
+Optimal objective  2.174035714e+02
+"""
+expected_summary_concurrent = {
+    "Status": "OPTIMAL",
+    "ObjVal": 217.4035714,
+    "BarIterCount": 7,
+    "IterCount": 69986,
+    "Runtime": 4.03,
+}
+expected_progress_concurrent = [
+    {
+        "Type": "barrier",
+        "Iteration": 0,
+        "Indicator": " ",
+        "PObj": 45643508.5,
+        "DObj": 15306.1018,
+        "PRes": 169000.0,
+        "DRes": 8.58,
+        "Compl": 1590.0,
+        "Time": 2,
+    },
+]
+
+example_log_relaxation = """
+Root relaxation: objective 4.473603e+00, 25 iterations, 0.01 seconds
+"""
+expected_summary_relaxation = {
+    "RelaxObj": 4.473603,
+    "RelaxIterCount": 25,
+    "RelaxTime": 0.01,
+}
+expected_progress_relaxation = []
+
+example_log_mip = """
+Root simplex log...
+
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+       0    4.2600000e+02   0.000000e+00   0.000000e+00     33s
+     473    4.2600000e+02   0.000000e+00   0.000000e+00     33s
+     473    4.2600000e+02   0.000000e+00   0.000000e+00     33s
+
+Root relaxation: objective 4.260000e+02, 473 iterations, 0.38 seconds
+"""
+expected_summary_mip = {
+    "RelaxObj": 426.0,
+    "RelaxIterCount": 473,
+    "RelaxTime": 0.38,
+}
+expected_progress_mip = [
+    {
+        "Type": "simplex",
+        "Iteration": 0,
+        "Objective": 426.0,
+        "PInf": 0.0,
+        "DInf": 0.0,
+        "Time": 33,
+    },
+    {
+        "Type": "simplex",
+        "Iteration": 473,
+        "Objective": 426.0,
+        "PInf": 0.0,
+        "DInf": 0.0,
+        "Time": 33,
+    },
+    {
+        "Type": "simplex",
+        "Iteration": 473,
+        "Objective": 426.0,
+        "PInf": 0.0,
+        "DInf": 0.0,
+        "Time": 33,
+    },
+]
+
+
+class TestContinuous(TestCase):
+    def test_last_progress_entry_barrier_with_simplex(self):
+        continuous_parser = ContinuousParser()
+        parse_block(continuous_parser, example_log_barrier_with_simplex)
+        self.assertEqual(
+            continuous_parser.get_progress()[-1], expected_progress_last_entry
+        )
+
+    def test_get_summary_progress(self):
+        for example_log, expected_summary, expected_progress in zip(
+            [
+                example_log_concurrent,
+                example_log_relaxation,
+                example_log_mip,
+            ],
+            [
+                expected_summary_concurrent,
+                expected_summary_relaxation,
+                expected_summary_mip,
+            ],
+            [
+                expected_progress_concurrent,
+                expected_progress_relaxation,
+                expected_progress_mip,
+            ],
+        ):
+            with self.subTest(example_log=example_log):
+                continuous_parser = ContinuousParser()
+                parse_block(continuous_parser, example_log)
+                self.assertEqual(continuous_parser.get_summary(), expected_summary)
+                self.assertEqual(continuous_parser.get_progress(), expected_progress)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_header.py b/tests/parsers/test_header.py
new file mode 100644
index 0000000..6cf613b
--- /dev/null
+++ b/tests/parsers/test_header.py
@@ -0,0 +1,139 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.header import HeaderParser
+from grblogtools.parsers.util import parse_lines
+
+example_log_0 = """
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Copyright (c) 2021, Gurobi Optimization, LLC
+
+Read MPS format model from file /Library/gurobi950/macos_universal2/examples/data/glass4.mps
+Reading time = 0.00 seconds
+glass4: 396 rows, 322 columns, 1815 nonzeros
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+"""
+
+expected_summary_0 = {
+    "Version": "9.5.0",
+    "ModelFilePath": "/Library/gurobi950/macos_universal2/examples/data/glass4.mps",
+    "ReadingTime": 0.0,
+    "PhysicalCores": 8,
+    "LogicalProcessors": 8,
+    "Threads": 8,
+    "ModelName": "glass4",
+    "Rows": 396,
+    "Columns": 322,
+    "Nonzeros": 1815,
+}
+
+expected_parameters_0 = {}
+
+example_log_1 = """
+Set parameter Presolve to value 0
+Set parameter NonConvex to value 2
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[rosetta2])
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+"""
+
+expected_summary_1 = {
+    "Version": "9.5.0",
+    "PhysicalCores": 8,
+    "LogicalProcessors": 8,
+    "Threads": 8,
+}
+
+expected_parameters_1 = {
+    "Presolve": 0,
+    "NonConvex": 2,
+}
+
+example_log_2 = """
+Set parameter CSManager to value "localhost:61000"
+Set parameter CSAuthToken
+Compute Server job ID: 4e90605d-8ec1-4b56-8351-d8a5355ff641
+Capacity available on 'localhost' - connecting...
+Established HTTP unencrypted connection
+Set parameter ConcurrentMIP to value 2
+Set parameter FuncPieces to value 1
+Set parameter FuncPieceLength to value 0.001
+Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[rosetta2])
+Gurobi Compute Server Worker version 9.5.0 build v9.5.0rc5 (mac64[arm])
+Thread count: 8 physical cores, 8 logical processors, using up to 8 threads
+"""
+
+expected_summary_2 = {
+    "JobID": "4e90605d-8ec1-4b56-8351-d8a5355ff641",
+    "Version": "9.5.0",
+    "Platform": "mac64[arm]",
+    "PhysicalCores": 8,
+    "LogicalProcessors": 8,
+    "Threads": 8,
+}
+
+expected_parameters_2 = {
+    "CSManager": '"localhost:61000"',
+    "FuncPieces": 1,
+    "FuncPieceLength": 0.001,
+    "ConcurrentMIP": 2,
+}
+
+
+class TestHeader(TestCase):
+    def setUp(self):
+        pass
+
+    def test_first_line_matched(self):
+        expected_start_lines = [
+            "Gurobi Optimizer version 9.5.0 build v9.5.0rc5 (mac64[arm])",
+            "Set parameter Presolve to value 0",
+            'Set parameter CSManager to value "localhost:61000"',
+        ]
+
+        for i, example_log in enumerate([example_log_0, example_log_1, example_log_2]):
+            with self.subTest(example_log=example_log):
+                header_parser = HeaderParser()
+                for line in example_log.strip().split("\n"):
+                    if header_parser.parse(line):
+                        self.assertEqual(line, expected_start_lines[i])
+                        break
+                else:
+                    self.assertRaises("No start line found.")
+
+    def test_get_summary(self):
+        example_logs = [example_log_0, example_log_1, example_log_2]
+        expected_summaries = [
+            expected_summary_0,
+            expected_summary_1,
+            expected_summary_2,
+        ]
+        expected_parameter_sets = [
+            expected_parameters_0,
+            expected_parameters_1,
+            expected_parameters_2,
+        ]
+        for example_log, expected_summary, expected_parameters in zip(
+            example_logs, expected_summaries, expected_parameter_sets
+        ):
+            with self.subTest(example_log=example_log):
+                header_parser = HeaderParser()
+                lines = example_log.strip().split("\n")
+                parse_lines(header_parser, lines)
+                self.assertEqual(header_parser.get_summary(), expected_summary)
+                self.assertEqual(header_parser.get_parameters(), expected_parameters)
+
+    def test_start_patterns(self):
+        """Check the header parser properly guards later patterns. This is
+        important so that when parsing multiple logs, the header parser does
+        not interrupt.
+
+        The below presolve line can be caught by the model name/size parser
+        in the header, but it should only be picked up if the HeaderParser
+        has seen a proper log start line."""
+
+        parser = HeaderParser()
+        parse_lines(parser, ["Presolved: 390 rows, 316 columns, 1803 nonzeros"])
+        assert not parser.get_summary()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_nodelog.py b/tests/parsers/test_nodelog.py
new file mode 100644
index 0000000..d142810
--- /dev/null
+++ b/tests/parsers/test_nodelog.py
@@ -0,0 +1,186 @@
+from grblogtools.parsers.nodelog import NodeLogParser
+from grblogtools.parsers.util import parse_block
+
+nodelog_section_test_data = """
+Variable types: 19 continuous, 297 integer (297 binary)
+Found heuristic solution: objective 3.133356e+09
+
+Root relaxation: objective 8.000024e+08, 72 iterations, 0.00 seconds
+
+    Nodes    |    Current Node    |     Objective Bounds      |     Work
+ Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time
+
+     0     0 8.0000e+08    0   72          - 8.0000e+08  74.5%     -    0s
+     0     0 8.0000e+08    0   72 3.1334e+09 8.0000e+08  74.5%     -    0s
+H    0     0                    2.200019e+09 8.0000e+08  63.6%     -    0s
+ 29986 17212 1.5267e+09   68  108 1.6500e+09 8.8832e+08  46.2%   4.4   15s
+ 40414 18935 infeasible  300      1.6000e+09 9.0001e+08  43.0%   5.7   25s
+*187499 14704             320    1.350013e+09 1.2000e+09  11.1%   7.3   35s
+
+Explored 188145 nodes (1383139 simplex iterations) in 35.66 seconds
+Best objective 1.200012600000e+09, best bound 1.200003400000e+09, gap 0.0008%
+"""
+
+
+nodelog_section_test_data_withcuts = """
+    Nodes    |    Current Node    |     Objective Bounds      |     Work
+ Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time
+
+     0     0 8.0000e+08    0   72          - 8.0000e+08  74.5%     -    0s
+
+Cutting planes:
+  Gomory: 13
+  Implied bound: 5
+  MIR: 20
+  Relax-and-lift: 8
+
+Explored 188 nodes (1389 simplex iterations) in 5.2 seconds
+Best objective 5.0000e+08, best bound 5.0000e+08, gap 0.0008%
+"""
+
+
+nodelog_section_test_data_statsonly = """
+Cutting planes:
+  Gomory: 13
+  Implied bound: 5
+  MIR: 20
+  Relax-and-lift: 8
+
+Explored 188 nodes (1389 simplex iterations) in 5.2 seconds
+Best objective 5.0000e+08, best bound 5.0000e+08, gap 0.0008%
+"""
+
+
+def test_nodelog_parser_statsonly():
+    parser = NodeLogParser()
+    parse_block(parser, nodelog_section_test_data_statsonly)
+    assert parser.get_summary() == {
+        "Cuts: Gomory": 13,
+        "Cuts: Implied bound": 5,
+        "Cuts: MIR": 20,
+        "Cuts: Relax-and-lift": 8,
+        "NodeCount": 188,
+        "IterCount": 1389,
+        "Runtime": 5.2,
+        "MIPGap": 8e-06,
+        "ObjBound": 5e8,
+        "ObjVal": 5e8,
+    }
+    assert parser.get_progress() == [
+        {
+            "Incumbent": 5e8,
+            "BestBd": 5e8,
+            "Gap": 8e-06,
+            "CurrentNode": 188,
+            "Time": 5.2,
+        }
+    ]
+
+
+def test_nodelog_parser_withcuts():
+    parser = NodeLogParser()
+    parse_block(parser, nodelog_section_test_data_withcuts)
+    assert parser.get_summary() == {
+        "Cuts: Gomory": 13,
+        "Cuts: Implied bound": 5,
+        "Cuts: MIR": 20,
+        "Cuts: Relax-and-lift": 8,
+        "NodeCount": 188,
+        "IterCount": 1389,
+        "Runtime": 5.2,
+        "MIPGap": 8e-06,
+        "ObjBound": 5e8,
+        "ObjVal": 5e8,
+    }
+    assert len(parser.get_progress()) == 2
+
+
+def test_nodelog_parser():
+    """Pass all test lines in sequence and test timeline."""
+    parser = NodeLogParser()
+    parse_block(parser, nodelog_section_test_data)
+    assert parser.get_summary() == {
+        "NodeCount": 188145,
+        "IterCount": 1383139,
+        "Runtime": 35.66,
+        "MIPGap": 8e-06,
+        "ObjBound": 1200003400.0,
+        "ObjVal": 1200012600.0,
+    }
+    assert parser.get_progress() == [
+        {
+            "CurrentNode": 0,
+            "RemainingNodes": 0,
+            "Obj": 8e8,
+            "Depth": 0,
+            "IntInf": 72,
+            "Incumbent": None,
+            "BestBd": 8e8,
+            "Gap": 0.745,
+            "ItPerNode": None,
+            "Time": 0.0,
+        },
+        {
+            "CurrentNode": 0,
+            "RemainingNodes": 0,
+            "Obj": 8e8,
+            "Depth": 0,
+            "IntInf": 72,
+            "Incumbent": 3.1334e09,
+            "BestBd": 8e8,
+            "Gap": 0.745,
+            "ItPerNode": None,
+            "Time": 0.0,
+        },
+        {
+            "NewSolution": "H",
+            "CurrentNode": 0,
+            "RemainingNodes": 0,
+            "Incumbent": 2.200019e9,
+            "BestBd": 8e8,
+            "Gap": 0.636,
+            "ItPerNode": None,
+            "Time": 0.0,
+        },
+        {
+            "CurrentNode": 29986,
+            "RemainingNodes": 17212,
+            "Obj": 1.5267e9,
+            "Depth": 68,
+            "IntInf": 108,
+            "Incumbent": 1.6500e9,
+            "BestBd": 8.8832e08,
+            "Gap": 0.462,
+            "ItPerNode": 4.4,
+            "Time": 15.0,
+        },
+        {
+            "CurrentNode": 40414,
+            "RemainingNodes": 18935,
+            "Pruned": "infeasible",
+            "Depth": 300,
+            "Incumbent": 1.600e9,
+            "BestBd": 9.0001e08,
+            "Gap": 0.43,
+            "ItPerNode": 5.7,
+            "Time": 25.0,
+        },
+        {
+            "NewSolution": "*",
+            "CurrentNode": 187499,
+            "RemainingNodes": 14704,
+            "Depth": 320,
+            "Incumbent": 1.350013e9,
+            "BestBd": 1.2e9,
+            "Gap": 0.111,
+            "ItPerNode": 7.3,
+            "Time": 35.0,
+        },
+        {
+            "CurrentNode": 188145,
+            "Incumbent": 1200012600.0,
+            "BestBd": 1200003400.0,
+            "Gap": 8e-06,
+            "Time": 35.66,
+        },
+    ]
diff --git a/tests/parsers/test_norel.py b/tests/parsers/test_norel.py
new file mode 100644
index 0000000..bcffeaa
--- /dev/null
+++ b/tests/parsers/test_norel.py
@@ -0,0 +1,71 @@
+from grblogtools.parsers.norel import NoRelParser
+from grblogtools.parsers.util import parse_block
+
+norel_section_test_data = """
+Variable types: 19 continuous, 297 integer (297 binary)
+Found heuristic solution: objective 3.133356e+09
+Starting NoRel heuristic
+Elapsed time for NoRel heuristic: 5s
+Found heuristic solution: objective 3.033354e+09
+Found heuristic solution: objective 1.450014e+09
+Elapsed time for NoRel heuristic: 10s (best bound 8.00002e+08)
+Found heuristic solution: objective 1.400013e+09
+Elapsed time for NoRel heuristic: 16s (best bound 8.00002e+08)
+Found heuristic solution: objective 1.200013e+09
+Elapsed time for NoRel heuristic: 21s (best bound 8.00002e+08)
+Elapsed time for NoRel heuristic: 27s (best bound 8.00002e+08)
+Elapsed time for NoRel heuristic: 93s (best bound 8.00002e+08)
+
+Root simplex log...
+"""
+
+norel_section_test_data_nobound = """
+Starting NoRel heuristic
+Elapsed time for NoRel heuristic: 5s
+Found heuristic solution: objective 1.450014e+09
+Elapsed time for NoRel heuristic: 10s
+"""
+
+norel_section_test_data_nosol = """
+Starting NoRel heuristic
+Elapsed time for NoRel heuristic: 5s
+"""
+
+
+def test_empty():
+    """To check that timeline to summary conversion is sane."""
+    parser = NoRelParser()
+    assert parser.get_progress() == []
+    assert parser.get_summary() == {}
+
+
+def test_norel_parser():
+    """Pass all test lines in sequence and test summary/timeline."""
+    parser = NoRelParser()
+    parse_block(parser, norel_section_test_data)
+    # only the 'Root simplex log...' line was passed but not parsed.
+    assert parser.get_summary() == {
+        "NoRelBestSol": 1.200013e9,
+        "NoRelTime": 93.0,
+        "NoRelBestBd": 8.00002e8,
+    }
+    assert parser.get_progress() == [
+        {"Time": 5.0},
+        {"Time": 10.0, "BestBd": 8.00002e8, "Incumbent": 1.450014e9},
+        {"Time": 16.0, "BestBd": 8.00002e8, "Incumbent": 1.400013e9},
+        {"Time": 21.0, "BestBd": 8.00002e8, "Incumbent": 1.200013e9},
+        {"Time": 27.0, "BestBd": 8.00002e8, "Incumbent": 1.200013e9},
+        {"Time": 93.0, "BestBd": 8.00002e8, "Incumbent": 1.200013e9},
+    ]
+
+
+def test_nobound():
+    parser = NoRelParser()
+    parse_block(parser, norel_section_test_data_nobound)
+    assert parser.get_summary() == {"NoRelTime": 10.0, "NoRelBestSol": 1.450014e9}
+
+
+def test_nosol():
+    parser = NoRelParser()
+    parse_block(parser, norel_section_test_data_nosol)
+    assert parser.get_summary() == {"NoRelTime": 5.0}
diff --git a/tests/parsers/test_presolve.py b/tests/parsers/test_presolve.py
new file mode 100644
index 0000000..4215195
--- /dev/null
+++ b/tests/parsers/test_presolve.py
@@ -0,0 +1,165 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.presolve import PresolveParser
+from grblogtools.parsers.util import parse_lines
+
+example_log_0 = """
+Optimize a model with 396 rows, 322 columns and 1815 nonzeros
+Model fingerprint: 0x18b19fdf
+Variable types: 20 continuous, 302 integer (0 binary)
+Coefficient statistics:
+  Matrix range     [1e+00, 8e+06]
+  Objective range  [1e+00, 1e+06]
+  Bounds range     [1e+00, 8e+02]
+  RHS range        [1e+00, 8e+06]
+Presolve removed 6 rows and 6 columns
+Presolve time: 0.01s
+Presolved: 390 rows, 316 columns, 1803 nonzeros
+Variable types: 19 continuous, 297 integer (297 binary)
+Found heuristic solution: objective 3.133356e+09
+"""
+
+expected_summary_0 = {
+    "NumConstrs": 396,
+    "NumVars": 322,
+    "NumNZs": 1815,
+    "Fingerprint": "0x18b19fdf",
+    "PresolvedNumConVars": 19,
+    "PresolvedNumIntVars": 297,
+    "PresolvedNumBinVars": 297,
+    "MinCoeff": 1.0,
+    "MaxCoeff": 8000000.0,
+    "MinObjCoeff": 1.0,
+    "MaxObjCoeff": 1000000.0,
+    "MinBound": 1.0,
+    "MaxBound": 800.0,
+    "MinRHS": 1.0,
+    "MaxRHS": 8000000.0,
+    "PresolveTime": 0.01,
+    "PresolvedNumConstrs": 390,
+    "PresolvedNumVars": 316,
+    "PresolvedNumNZs": 1803,
+}
+
+example_log_1 = """
+Optimize a model with 1 rows, 3 columns and 3 nonzeros
+Model fingerprint: 0x83663ee2
+Model has 2 quadratic constraints
+Coefficient statistics:
+  Matrix range     [1e+00, 1e+00]
+  QMatrix range    [1e+00, 1e+00]
+  Objective range  [1e+00, 1e+00]
+  Bounds range     [0e+00, 0e+00]
+  RHS range        [1e+01, 1e+01]
+  QRHS range       [1e+00, 2e+00]
+
+Continuous model is non-convex -- solving as a MIP
+
+Presolve: All rows and columns removed
+"""
+
+expected_summary_1 = {
+    "NumConstrs": 1,
+    "NumVars": 3,
+    "NumNZs": 3,
+    "Fingerprint": "0x83663ee2",
+    "NumQConstrs": 2,
+    "MinCoeff": 1.0,
+    "MaxCoeff": 1.0,
+    "MinQCCoeff": 1.0,
+    "MaxQCCoeff": 1.0,
+    "MinObjCoeff": 1.0,
+    "MaxObjCoeff": 1.0,
+    "MinBound": 0.0,
+    "MaxBound": 0.0,
+    "MinRHS": 10.0,
+    "MaxRHS": 10.0,
+    "MinQCRHS": 1.0,
+    "MaxQCRHS": 2.0,
+    "PresolvedNumConstrs": 0,
+    "PresolvedNumVars": 0,
+    "PresolvedNumNZs": 0,
+}
+
+example_log_2 = """
+Optimize a model with 1 rows, 4 columns and 2 nonzeros
+Model fingerprint: 0x741a3617
+Model has 2 general constraints
+Variable types: 4 continuous, 0 integer (0 binary)
+Coefficient statistics:
+  Matrix range     [1e+00, 4e+00]
+  Objective range  [1e+00, 2e+00]
+  Bounds range     [0e+00, 0e+00]
+  RHS range        [9e+00, 9e+00]
+
+Concurrent MIP optimizer: 2 concurrent instances (4 threads per instance)
+
+Presolve added 6 rows and 7263 columns
+Presolve time: 0.02s
+Presolved: 7 rows, 7267 columns, 21791 nonzeros
+Presolved model has 2 SOS constraint(s)
+Variable types: 7267 continuous, 0 integer (0 binary)
+"""
+
+expected_summary_2 = {
+    "NumConstrs": 1,
+    "NumVars": 4,
+    "NumNZs": 2,
+    "Fingerprint": "0x741a3617",
+    "NumGenConstrs": 2,
+    "PresolvedNumConVars": 7267,
+    "PresolvedNumIntVars": 0,
+    "PresolvedNumBinVars": 0,
+    "MinCoeff": 1.0,
+    "MaxCoeff": 4.0,
+    "MinObjCoeff": 1.0,
+    "MaxObjCoeff": 2.0,
+    "MinBound": 0.0,
+    "MaxBound": 0.0,
+    "MinRHS": 9.0,
+    "MaxRHS": 9.0,
+    "ConcurrentJobs": 2,
+    "PresolveTime": 0.02,
+    "PresolvedNumConstrs": 7,
+    "PresolvedNumVars": 7267,
+    "PresolvedNumNZs": 21791,
+}
+
+
+class TestPresolve(TestCase):
+    def setUp(self):
+        pass
+
+    def test_first_line_matched(self):
+        expected_start_lines = [
+            "Optimize a model with 396 rows, 322 columns and 1815 nonzeros",
+            "Optimize a model with 1 rows, 3 columns and 3 nonzeros",
+            "Optimize a model with 1 rows, 4 columns and 2 nonzeros",
+        ]
+
+        for i, example_log in enumerate([example_log_0, example_log_1, example_log_2]):
+            with self.subTest(example_log=example_log):
+                presolve_parser = PresolveParser()
+                for line in example_log.strip().split("\n"):
+                    if presolve_parser.parse(line):
+                        self.assertEqual(line, expected_start_lines[i])
+                        break
+                else:
+                    self.assertRaises("No start line found.")
+
+    def test_get_summary(self):
+        expected_summaries = [
+            expected_summary_0,
+            expected_summary_1,
+            expected_summary_2,
+        ]
+        for i, example_log in enumerate([example_log_0, example_log_1, example_log_2]):
+            with self.subTest(example_log=example_log):
+                presolve_parser = PresolveParser()
+                lines = example_log.strip().split("\n")
+                parse_lines(presolve_parser, lines)
+                self.assertEqual(presolve_parser.get_summary(), expected_summaries[i])
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_simplex.py b/tests/parsers/test_simplex.py
new file mode 100644
index 0000000..c877914
--- /dev/null
+++ b/tests/parsers/test_simplex.py
@@ -0,0 +1,55 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.simplex import SimplexParser
+from grblogtools.parsers.util import parse_block
+
+example_log_simplex = """
+Iteration    Objective       Primal Inf.    Dual Inf.      Time
+       0    0.0000000e+00   0.000000e+00   0.000000e+00      0s
+       0    4.4000000e+02   0.000000e+00   5.102000e+01      0s
+      17   -1.5907818e+00   0.000000e+00   0.000000e+00      0s
+
+Solved in 17 iterations and 0.01 seconds
+Optimal objective -1.590781794e+00
+"""
+expected_summary_simplex = {"IterCount": 17, "Runtime": 0.01}
+expected_progress_simplex = [
+    {
+        "Type": "simplex",
+        "Iteration": 0,
+        "Objective": 0.0,
+        "PInf": 0.0,
+        "DInf": 0.0,
+        "Time": 0,
+    },
+    {
+        "Type": "simplex",
+        "Iteration": 0,
+        "Objective": 440.0,
+        "PInf": 0.0,
+        "DInf": 51.02,
+        "Time": 0,
+    },
+    {
+        "Type": "simplex",
+        "Iteration": 17,
+        "Objective": -1.5907818,
+        "PInf": 0.0,
+        "DInf": 0.0,
+        "Time": 0,
+    },
+]
+
+
+class TestSimplex(TestCase):
+    def setUp(self):
+        self.simplex_parser = SimplexParser()
+
+    def test_get_summary_progress(self):
+        parse_block(self.simplex_parser, example_log_simplex)
+        self.assertEqual(self.simplex_parser.get_summary(), expected_summary_simplex)
+        self.assertEqual(self.simplex_parser.get_progress(), expected_progress_simplex)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_single_log.py b/tests/parsers/test_single_log.py
new file mode 100644
index 0000000..669b7cd
--- /dev/null
+++ b/tests/parsers/test_single_log.py
@@ -0,0 +1,84 @@
+from grblogtools.parsers.single_log import SingleLogParser
+from grblogtools.parsers.util import parse_lines
+
+
+def test_mip_norel_log():
+    parser = SingleLogParser()
+    with open("tests/assets/mip_norel.log") as infile:
+        parse_lines(parser, infile)
+    # Check that something is populated for all parsers.
+    assert parser.header_parser.get_summary()
+    assert parser.presolve_parser.get_summary()
+    assert parser.norel_parser.get_summary()
+    assert parser.norel_parser.get_progress()
+    assert parser.continuous_parser.get_summary()
+    assert parser.continuous_parser.get_progress()
+    assert parser.nodelog_parser.get_summary()
+    assert parser.nodelog_parser.get_progress()
+    assert parser.termination_parser.get_summary()
+    # Combined summary data.
+    summary = parser.get_summary()
+    assert summary["Version"] == "9.1.2"
+    assert summary["Platform"] == "mac64, gurobi_cl"
+    assert summary["NumVars"] == 322
+    assert summary["PresolvedNumIntVars"] == 297
+    assert summary["NoRelBestSol"] == 1.200013e09
+    assert summary["NodeCount"] == 5135
+    assert summary["Status"] == "OPTIMAL"
+    assert summary["ObjVal"] == 1.2000126e09
+    assert summary["Runtime"] == 93.70
+    assert summary["ModelType"] == "MIP"
+
+    norel_progress = parser.norel_parser.get_progress()
+    assert len(norel_progress) == 15
+    rootlp_progress = parser.continuous_parser.get_progress()
+    assert len(rootlp_progress) == 2
+    nodelog_progress = parser.nodelog_parser.get_progress()
+    assert len(nodelog_progress) == 7
+
+
+def test_lp_barrier():
+    parser = SingleLogParser()
+    with open("tests/assets/lp_barrier.log") as infile:
+        parse_lines(parser, infile)
+    # Test relevant bits are populated.
+    assert parser.header_parser.get_summary()
+    assert parser.presolve_parser.get_summary()
+    assert parser.continuous_parser.get_summary()
+    assert not parser.norel_parser.get_summary()
+    assert not parser.nodelog_parser.get_summary()
+    # Combined summary data.
+    summary = parser.get_summary()
+    assert summary["Version"] == "9.5.0"
+    assert summary["ModelName"] == "savsched1.mps"
+    assert summary["OrderingTime"] == 0.41
+    assert summary["BarIterCount"] == 17
+    assert summary["Runtime"] == 9.18
+    assert summary["Status"] == "OPTIMAL"
+    assert summary["ModelType"] == "LP"
+
+    rootlp_progress = parser.continuous_parser.get_progress()
+    assert len(rootlp_progress) == 19
+
+
+def test_lp_simplex():
+    parser = SingleLogParser()
+    with open("tests/assets/lp_simplex.log") as infile:
+        parse_lines(parser, infile)
+    # Test relevant bits are populated.
+    assert parser.header_parser.get_summary()
+    assert parser.presolve_parser.get_summary()
+    assert parser.continuous_parser.get_summary()
+    assert not parser.norel_parser.get_summary()
+    assert not parser.nodelog_parser.get_summary()
+    assert parser.termination_parser.get_summary()
+    # Combined summary data.
+    summary = parser.get_summary()
+    assert summary["Version"] == "9.5.0"
+    assert summary["IterCount"] == 75321
+    assert summary["Runtime"] == 300.00
+    assert summary["Status"] == "TIME_LIMIT"
+    assert summary["ModelType"] == "LP"
+
+    rootlp_progress = parser.continuous_parser.get_progress()
+    assert len(rootlp_progress) == 60
diff --git a/tests/parsers/test_termination.py b/tests/parsers/test_termination.py
new file mode 100644
index 0000000..158c0e3
--- /dev/null
+++ b/tests/parsers/test_termination.py
@@ -0,0 +1,66 @@
+from unittest import TestCase, main
+
+from grblogtools.parsers.termination import TerminationParser
+from grblogtools.parsers.util import parse_block
+
+example_log = """
+Barrier solved model in 17 iterations and 4.83 seconds (6.45 work units)
+Optimal objective 8.000024e+08
+
+Root relaxation: objective 8.000024e+08, 72 iterations, 30.00 seconds
+
+Explored 5135 nodes (36786 simplex iterations) in 93.70 seconds
+Thread count was 8 (of 8 available processors)
+
+Solution count 10: 1.20001e+09 1.40001e+09 1.45001e+09 ... 1.60001e+09
+
+Optimal solution found (tolerance 1.00e-04)
+Best objective 1.200012600000e+09, best bound 1.200006450000e+09, gap 0.0005%
+"""
+
+expected_summary = {
+    "Status": "OPTIMAL",
+    "SolCount": 10,
+    "Threads": 8,
+    "Cores": 8,
+}
+
+
+interrupted_log = """
+Explored 3818 nodes (2594430 simplex iterations) in 9431.94 seconds
+Thread count was 8 (of 8 available processors)
+
+Solution count 4: -1.72151e+08 -1.71457e+08 -1.70301e+08 -1.49849e+08
+
+Solve interrupted
+Best objective -1.721508552690e+08, best bound -1.763085601859e+08, gap 2.4152%
+"""
+
+interrupted_summary = {
+    "Status": "INTERRUPTED",
+    "SolCount": 4,
+    "Threads": 8,
+    "Cores": 8,
+    "ErrorCode": None,
+}
+
+
+class TestTermination(TestCase):
+    def setUp(self):
+        pass
+
+    def test_get_summary(self):
+        parser = TerminationParser()
+        lines = iter(example_log.strip().split("\n"))
+        for line in lines:
+            parser.parse(line)
+        self.assertEqual(parser.get_summary(), expected_summary)
+
+    def test_interrupted(self):
+        parser = TerminationParser()
+        parse_block(parser, interrupted_log)
+        self.assertEqual(parser.get_summary(), interrupted_summary)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/parsers/test_util.py b/tests/parsers/test_util.py
new file mode 100644
index 0000000..c1136c8
--- /dev/null
+++ b/tests/parsers/test_util.py
@@ -0,0 +1,20 @@
+import pytest
+
+from grblogtools.parsers.util import model_type
+
+
+@pytest.mark.parametrize(
+    "kwargs, result",
+    [
+        ({}, "LP"),
+        ({"discrete_vars": 1}, "MIP"),
+        ({"quad_nonzeros": 1}, "QP"),
+        ({"discrete_vars": 1, "quad_nonzeros": 1}, "MIQP"),
+        ({"quad_constrs": 1}, "QCP"),
+        ({"quad_nonzeros": 1, "quad_constrs": 1}, "QCP"),
+        ({"discrete_vars": 1, "quad_constrs": 1}, "MIQCP"),
+        ({"discrete_vars": 1, "quad_nonzeros": 1, "quad_constrs": 1}, "MIQCP"),
+    ],
+)
+def test_model_type(kwargs, result):
+    assert model_type(**kwargs) == result
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..a7cd37c
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,181 @@
+import glob
+import tempfile
+
+import pandas as pd
+import pytest
+from pandas.api.types import is_integer_dtype
+from pandas.testing import assert_frame_equal, assert_series_equal
+
+import grblogtools as glt
+
+
+@pytest.fixture(scope="module")
+def glass4_summary():
+    """Summary data from API call."""
+    return glt.parse("data/*.log").summary()
+
+
+@pytest.fixture(scope="module")
+def glass4_progress():
+    """Progress data from API call."""
+    return {
+        "norel": glt.parse("data/*.log").progress("norel"),
+        "rootlp": glt.parse("data/*.log").progress("rootlp"),
+        "nodelog": glt.parse("data/*.log").progress("nodelog"),
+    }
+
+
+@pytest.fixture(scope="module")
+def testlog_summary():
+    return glt.parse("tests/assets/*.log").summary()
+
+
+@pytest.fixture(scope="module")
+def testlog_progress():
+    return {
+        "norel": glt.parse("tests/assets/*.log").progress("norel"),
+        "rootlp": glt.parse("tests/assets/*.log").progress("rootlp"),
+        "nodelog": glt.parse("tests/assets/*.log").progress("nodelog"),
+    }
+
+
+@pytest.fixture(scope="module")
+def merged_log():
+    with tempfile.NamedTemporaryFile("w") as fp:
+        for path in sorted(glob.glob("data/912-glass4-*.log")):
+            with open(path) as infile:
+                fp.writelines(infile.readlines())
+        fp.flush()
+        yield fp.name
+
+
+def test_merged_log(merged_log):
+    summary = glt.parse(merged_log).summary()
+    result = summary[["Seed", "Runtime", "LogFilePath", "LogNumber"]]
+    expected = pd.DataFrame(
+        [
+            {"Seed": 0, "Runtime": 35.66, "LogFilePath": merged_log, "LogNumber": 1},
+            {"Seed": 1, "Runtime": 42.79, "LogFilePath": merged_log, "LogNumber": 2},
+            {"Seed": 2, "Runtime": 11.37, "LogFilePath": merged_log, "LogNumber": 3},
+        ]
+    )
+    assert_frame_equal(result, expected)
+
+
+def test_summary(testlog_summary):
+    assert len(testlog_summary) == 7
+    assert set(testlog_summary.columns).issuperset(
+        {"Status", "ObjVal", "ReadingTime", "RelaxObj"}
+    )
+
+
+def test_progress(testlog_progress):
+    assert len(testlog_progress) == 3
+    assert len(testlog_progress["norel"]) == 15
+    assert set(testlog_progress["norel"].columns).issuperset(
+        {"Time", "BestBd", "Incumbent"}
+    )
+    assert len(testlog_progress["rootlp"]) == 409
+    assert set(testlog_progress["rootlp"].columns).issuperset(
+        {"Iteration", "PInf", "DInf", "PObj", "DObj"}
+    )
+    assert len(testlog_progress["nodelog"]) == 133
+    assert set(testlog_progress["nodelog"].columns).issuperset(
+        {"Depth", "IntInf", "Incumbent", "BestBd", "ItPerNode", "ModelFile", "Version"}
+    )
+
+
+def test_summary_glass4(glass4_summary):
+    assert len(glass4_summary) == 63
+    assert set(glass4_summary.columns).issuperset(
+        {"Status", "ObjVal", "ReadingTime", "RelaxObj", "Seed"}
+    )
+
+
+def test_progress_glass4(glass4_progress):
+    assert len(glass4_progress) == 3
+    assert len(glass4_progress["norel"]) == 0
+    assert len(glass4_progress["rootlp"]) == 0
+
+    assert set(glass4_progress["nodelog"].columns).issuperset(
+        {"Depth", "IntInf", "Incumbent", "BestBd", "ItPerNode"}
+    )
+
+
+def test_logfile(glass4_summary):
+    logfiles = glass4_summary["LogFilePath"]
+    assert len(logfiles.unique()) == len(logfiles)
+    assert logfiles.str.startswith("data/").all()
+    assert logfiles.str.endswith(".log").all()
+    assert_series_equal(
+        glass4_summary["LogFile (Parameter)"].apply(lambda l: "data/" + l),
+        logfiles,
+        check_names=False,
+    )
+    # log names are stripped of the model name and seed
+    log = glass4_summary["Log"]
+    assert log.str.startswith("912").all()
+    assert not log.str.contains("glass4").any()
+    assert not log.str.endswith("-").any()
+    assert len(log.unique()) == 21  # different seeds get same label
+
+
+def test_modelfile(glass4_summary):
+    modelfiles = glass4_summary["ModelFilePath"]
+    assert len(modelfiles.unique()) == 1
+    assert glass4_summary["ModelFile"].eq("glass4").all()
+    assert glass4_summary["Model"].eq("glass4").all()
+
+
+def test_parameters(glass4_summary):
+    seeds = glass4_summary["Seed"]
+    assert is_integer_dtype(seeds)
+    assert seeds.notnull().all()
+    assert set(seeds.unique()) == {0, 1, 2}
+    assert "TimeLimit (Parameter)" in glass4_summary.columns
+    parameters = glass4_summary[
+        [f"{c} (Parameter)" for c in ["Heuristics", "MIPFocus", "Presolve"]]
+    ]
+    assert parameters.notnull().all().all()
+
+
+def test_legacy_api():
+    glass4_summary = glt.get_dataframe(["data/*.log"])
+    assert glass4_summary.shape[0] == 63
+    assert set(glass4_summary.columns).issuperset({"Status", "ObjVal"})
+
+
+def test_legacy_api_twopattern():
+    """Check multiple file patterns can be passed and duplicate files are
+    filtered out."""
+    glass4_summary = glt.get_dataframe(
+        [
+            "data/912-glass4-*.log",
+            "data/912-Cuts0-glass4-*.log",
+            "data/912-Cuts0-glass4-*.log",
+        ]
+    )
+    assert glass4_summary.shape[0] == 6
+    assert set(glass4_summary.columns).issuperset({"Status", "ObjVal"})
+
+
+def test_listpattern():
+    result = glt.parse(
+        [
+            "data/912-glass4-*.log",
+            "data/912-Cuts0-glass4-*.log",
+            "data/912-Cuts0-glass4-*.log",
+        ]
+    )
+    glass4_summary = result.summary()
+    assert glass4_summary.shape[0] == 6
+    assert set(glass4_summary.columns).issuperset({"Status", "ObjVal"})
+
+
+def test_gurobipy_logs():
+    """API logs differ in some cases: sometimes no model file is read, and
+    parameter settings may appear before the header. At the moment this test
+    just asserts no errors."""
+    glt.parse("tests/assets/gurobipy/empty.log").summary()
+    glt.parse("tests/assets/gurobipy/read.log").summary()
+    glt.parse("tests/assets/gurobipy/*.log").summary()
diff --git a/tests/test_cuts.py b/tests/test_cuts.py
index b79997e..acf51c9 100644
--- a/tests/test_cuts.py
+++ b/tests/test_cuts.py
@@ -1,6 +1,7 @@
-from helpers import *
+from .helpers import read_single
+
 
 def test_cuts():
-    row = read_single('912-glass4-0.log')
-    cuts = { key: row[key] for key in row.keys() if key.startswith('Cuts: ') }
-    assert len(cuts) == 8
\ No newline at end of file
+    row = read_single("912-glass4-0.log")
+    cuts = {key: row[key] for key in row.keys() if key.startswith("Cuts: ")}
+    assert len(cuts) == 8
diff --git a/tests/test_fill_defaults.py b/tests/test_fill_defaults.py
index ec77226..05842af 100644
--- a/tests/test_fill_defaults.py
+++ b/tests/test_fill_defaults.py
@@ -1,8 +1,12 @@
-import grblogtools as glt
+import pandas as pd
+from pandas.api.types import is_categorical_dtype, is_float_dtype, is_integer_dtype
+from pandas.testing import assert_frame_equal
 
-from pandas.api.types import is_categorical_dtype
-from pandas.api.types import is_float_dtype
-from pandas.api.types import is_integer_dtype
+import grblogtools as glt
+from grblogtools.helpers import (
+    fill_default_parameters,
+    fill_default_parameters_nosuffix,
+)
 
 
 def test_parameter_values():
@@ -23,3 +27,72 @@ def test_pretty_parameters():
         "2: Aggressive",
     }
     assert is_float_dtype(summary["Heuristics (Parameter)"])
+
+
+def test_fill_default_parameters():
+    """Test that '* (Parameter)' columns are default-filled using the
+    appropriate Version."""
+    summary = pd.DataFrame(
+        [
+            {"Version": "9.1.2", "CrossoverBasis (Parameter)": 1, "Status": "OPTIMAL"},
+            {"Version": "9.1.2", "MIPFocus (Parameter)": 1, "Status": "TIME_LIMIT"},
+            {"Version": "9.5.0", "Heuristics (Parameter)": 0, "Status": "OPTIMAL"},
+        ]
+    )
+    filled = fill_default_parameters(summary)
+    expected = pd.DataFrame(
+        [
+            {
+                "Version": "9.1.2",
+                "CrossoverBasis (Parameter)": 1,
+                "MIPFocus (Parameter)": 0,
+                "Heuristics (Parameter)": 0.05,
+                "Status": "OPTIMAL",
+            },
+            {
+                "Version": "9.1.2",
+                "CrossoverBasis (Parameter)": 0,
+                "MIPFocus (Parameter)": 1,
+                "Heuristics (Parameter)": 0.05,
+                "Status": "TIME_LIMIT",
+            },
+            {
+                "Version": "9.5.0",
+                "CrossoverBasis (Parameter)": -1,
+                "MIPFocus (Parameter)": 0,
+                "Heuristics (Parameter)": 0.0,
+                "Status": "OPTIMAL",
+            },
+        ]
+    )
+    assert_frame_equal(filled.sort_index(axis=1), expected.sort_index(axis=1))
+
+
+def test_fill_default_parameters_nosuffix():
+    """The only columns in this input frame are the"""
+    parameters = pd.DataFrame(
+        [
+            {"Version": "9.1.2", "CrossoverBasis": 1},
+            {"Version": "9.1.2", "MIPFocus": 1},
+            {"Version": "9.5.0", "Heuristics": 0},
+        ]
+    )
+    filled = fill_default_parameters_nosuffix(parameters)
+    expected = pd.DataFrame(
+        [
+            {
+                "Version": "9.1.2",
+                "CrossoverBasis": 1,
+                "MIPFocus": 0,
+                "Heuristics": 0.05,
+            },
+            {
+                "Version": "9.1.2",
+                "CrossoverBasis": 0,
+                "MIPFocus": 1,
+                "Heuristics": 0.05,
+            },
+            {"Version": "9.5.0", "CrossoverBasis": -1, "MIPFocus": 0, "Heuristics": 0},
+        ]
+    )
+    assert_frame_equal(filled.sort_index(axis=1), expected.sort_index(axis=1))
diff --git a/tests/test_load.py b/tests/test_load.py
index 0e1e635..2f42f61 100644
--- a/tests/test_load.py
+++ b/tests/test_load.py
@@ -15,13 +15,26 @@ def test_read_with_timelines():
 def test_norel_timeline():
     """Check norel logs before the root node. Note that this reports norel's
     clock, so read + presolve time would need to be added for 'real' time."""
-    summary, timelines = glt.get_dataframe(
-        ["data/912-NoRelHeurWork60-glass4-0.log"],
+    _, timelines = glt.get_dataframe(
+        ["tests/assets/mip_norel.log"],
         timelines=True,
     )
     norel = timelines["norel"]
+    assert set(norel.columns) == {
+        "Time",
+        "BestBd",
+        "Incumbent",
+        "Log",
+        "LogFilePath",
+        "Model",
+        "ModelFile",
+        "Version",
+        "LogNumber",
+        "ModelFilePath",
+        "Seed",
+    }
     assert norel.shape[0] == 15
-    assert norel["Log"].unique()[0] == "912-NoRelHeurWork60"
+    assert len(norel["LogFilePath"].unique()) == 1
     assert norel["Time"].min() == 5.0
     assert norel["Time"].max() == 93.0
     assert (norel["Incumbent"].max() - 1.450014e09) <= 1e05
diff --git a/tests/test_plotting.py b/tests/test_plotting.py
new file mode 100644
index 0000000..e35b907
--- /dev/null
+++ b/tests/test_plotting.py
@@ -0,0 +1,16 @@
+import pytest
+
+import grblogtools as glt
+
+
+@pytest.fixture
+def glass4_results():
+    return glt.parse("data/912*glass4*.log")
+
+
+def test_plot_summary(glass4_results):
+    glt.plot(glass4_results.summary(), type="box")
+
+
+def test_plot_nodelog(glass4_results):
+    glt.plot(glass4_results.progress("nodelog"), y="Gap", color="Log", type="line")