Repository: xopt-org/Badger (language: python)

Issue:
ENH: Adds github actions support to routine refactor branch 
This PR adds GitHub actions support to this refactor branch 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..f1a14396
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,75 @@
+name: Tests
+
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: '0 0 * * *'  # every day at midnight
+
+jobs:
+  build:
+    if: ${{ github.repository == 'slaclab/Badger' }}
+    runs-on: ${{ matrix.os }}
+    defaults:
+      run:
+        shell: bash
+    strategy:
+      max-parallel: 1
+      matrix:
+        os: [ubuntu-latest]
+        python-version: ["3.9", "3.11"]
+    env:
+      DISPLAY: ':99.0'
+      QT_MAC_WANTS_LAYER: 1  # PyQT gui tests involving qtbot interaction on macOS will fail without this
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup Mambaforge
+        uses: conda-incubator/setup-miniconda@v2
+        with:
+          miniforge-variant: Mambaforge
+          miniforge-version: latest
+          activate-environment: badger-dev
+          use-mamba: true
+          python-version: ${{ matrix.python-version }}
+          environment-file: env/environment.yml
+
+      # Have to install pyqt5 with pip
+      - name: Install pyqt5
+        shell: bash -el {0}
+        run: |
+            pip install PyQt5
+
+      # TODO: Have to install xopt with pip for now
+      # should put xopt>=2.1 in the environment.yml later
+      # to install xopt with conda
+      - name: Install Xopt v2.1+
+        shell: bash -el {0}
+        run: |
+            pip install xopt
+
+      - name: Install python packages
+        shell: bash -el {0}
+        run: |
+          mamba install --file env/requirements.txt
+
+      - name: Install libs for testing a pyqt app on linux
+        shell: bash -el {0}
+        run: |
+          if [ "$RUNNER_OS" == "Linux" ]; then
+            sudo apt install xvfb herbstluftwm libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils
+            sudo /sbin/start-stop-daemon --start --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset
+            sleep 3
+            sudo /sbin/start-stop-daemon --start --pidfile /tmp/custom_herbstluftwm_99.pid --make-pidfile --background --exec /usr/bin/herbstluftwm
+            sleep 1
+          fi
+
+      - name: Install Badger
+        shell: bash -l {0}
+        run: |
+          pip install --no-dependencies .
+
+      - name: Run Tests
+        shell: bash -l {0}
+        run: |
+          python scripts/run_tests.py
\ No newline at end of file
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
deleted file mode 100644
index ae761691..00000000
--- a/.github/workflows/pre-commit.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-name: pre-commit
-
-on:
-  pull_request:
-  push:
-    branches: [master]
-
-jobs:
-  pre-commit:
-    runs-on: ubuntu-latest
-    steps:
-    - uses: actions/checkout@v3
-    - uses: actions/setup-python@v3
-    - uses: pre-commit/action@v3.0.0
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 53fb9514..52daaea8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,8 @@ __pycache__
 /dist
 /build
 /local
-/environment*.yml
 /requirements*.txt
+.idea/
+/examples/*.yaml
+*.pickle
+*.db
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 6ce4ae55..00000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-repos:
--   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
-    hooks:
-    -   id: end-of-file-fixer
-        files: '\.(py|txt)$' # Only run on .py and .txt files	
-    -   id: trailing-whitespace
-        files: '\.(py|txt)$' # Only run on .py and .txt files
--   repo: https://github.com/psf/black
-    rev: 23.7.0
-    hooks:
-    -   id: black
-        args: [--line-length, '120']
--   repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.0.287
-    hooks:
-    -   id: ruff
-        args: [--line-length, '120', --fix, --exit-non-zero-on-fix]
\ No newline at end of file
diff --git a/README.md b/README.md
index 1a5cfb14..35b8d654 100644
--- a/README.md
+++ b/README.md
@@ -1,382 +1,106 @@
-# Badger: The Ocelot Optimizer Rebirth
+<div align="center">
+  <h1 align="center">
+    Badger: The Missing Optimizer in ACR
+    <br />
+    <br />
+    <a href="https://slac-ml.github.io/Badger">
+      <img src="pics/badger.png" alt="Badger" height=128>
+    </a>
+  </h1>
+</div>
 
-## Installation
-
-### Install the Badger core
-
-Using `pip`: `pip install badger-opt`
-
-Or using `conda`: `conda install -c conda-forge badger-opt`
-
-### Get the Badger plugins
-
-Clone the [badger plugins repo](https://github.com/SLAC-ML/Badger-Plugins) to some directory on your computer.
-
-### Set up Badger
-
-Once `badger-opt` is installed and you have the badger plugins cloned, run the following command:
-
-```bash
-badger
-```
-
-Follow the instructions and configure several paths that are needed by Badger.
-
-## Usage
-
-For all the implemented and planned CLI usage, please refer to [these slides](https://docs.google.com/presentation/d/1APlLgaRik2VPGL7FuxEUmwHvx6egTeIRaxBKGS1TnsE/edit#slide=id.ge68b2a5657_0_5). We'll highlight several common CLI use cases of Badger in the following sections.
-
-### Get help
-
-```bash
-badger -h
-```
-
-Or [shoot me an email](mailto:zhezhang@slac.stanford.edu)!
-
-### Show metadata of Badger
-
-To show the version number and some other metadata such as plugin directory:
-
-```bash
-badger
-```
-
-### Get information of the algorithms
-
-List all the available algorithms:
-
-```bash
-badger algo
-```
-
-Get the configs of a specific algorithm:
-
-```bash
-badger algo ALGO_NAME
-```
-
-You'll get something like:
-
-```yaml
-name: silly
-version: '0.1'
-dependencies:
-  - numpy
-params:
-  dimension: 1
-  max_iter: 42
-```
+![Badger main GUI](pics/main.png)
 
-Note that in order to use this plugin, you'll need to install the dependencies listed in the command output. This dependency installation will be handled automatically if the plugin was installed through the `badger install` command, but that command is not available yet (it is coming soon).
+<div align="center">
 
-The `params` part shows all the intrinsic parameters that can be tuned when doing optimization with this algorithm.
+| Documentation | Package | Downloads | Version | Platforms |
+| --- | --- | --- | --- | --- |
+| [![Documentation](https://img.shields.io/badge/Badger-documentation-blue.svg)](https://slac-ml.github.io/Badger/) | [![Conda Recipe](https://img.shields.io/badge/recipe-badger-opt.svg)](https://anaconda.org/conda-forge/badger-opt) | [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/badger-opt.svg)](https://anaconda.org/conda-forge/badger-opt) | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/badger-opt.svg)](https://anaconda.org/conda-forge/badger-opt) | [![Conda Platforms](https://img.shields.io/conda/pn/conda-forge/badger-opt.svg)](https://anaconda.org/conda-forge/badger-opt) |
 
-### Get information of the environments
+</div>
 
-List all the available environments:
+## Introduction
 
-```bash
-badger env
-```
-
-Get the configs of a specific environment:
-
-```bash
-badger env ENV_NAME
-```
-
-The command will print out something like:
-
-```yaml
-name: dumb
-version: '0.1'
-dependencies:
-  - numpy
-  - badger-opt
-interface:
-  - silly
-environments:
-  - silly
-  - naive
-params: null
-variables:
-  - q1: 0 -> 1
-  - q2: 0 -> 1
-  - q3: 0 -> 1
-  - q4: 0 -> 1
-  - s1: 0 -> 1
-  - s2: 0 -> 1
-observations:
-  - l2
-  - mean
-  - l2_x_mean
-```
-
-There are several important properties here:
-
-- `variables`: The tunable variables provided by this environment. You could choose a subset of the variables as the desicion variables for the optimization in the routine config. The allowed ranges (in this case, 0 to 1) are shown behind the corresponding variable names
-- `observations`: The measurements provided by this environment. You could choose some observations as the objectives, and some other observations as the constraints in the routine config
-
-### Run and save an optimization
-
-```bash
-badger run [-h] -a ALGO_NAME [-ap ALGO_PARAMS] -e ENV_NAME [-ep ENV_PARAMS] -c ROUTINE_CONFIG [-s [SAVE_NAME]] [-y] [-v [{0,1,2}]]
-```
-
-The `-ap` and `-ep` optional arguments, and the `-c` argument accept either a `.yaml` file path or a yaml string. The configs set to `-ap` and `-ep` optional arguments should be treated as "patch" on the default algorithm and environment parameters, respectively, which means that you only need to specify the paramters that you'd like to change on top of the default configs, rather than pass in a full config. The content of the `ROUTINE_CONFIG` (aka routine configs) should look like this:
-
-```yaml
-variables:
-  - x1: [-1, 0.5]
-  - x2
-objectives:
-  - c1
-  - y2: MINIMIZE
-constraints:
-  - y1:
-      - GREATER_THAN
-      - 0
-  - c2:
-      - LESS_THAN
-      - 0.5
-```
+Badger is an optimization interface tailored for the Accelerator Control Room (ACR)[^1]. It places a strong emphasis on extensibility, easily expandable through the plugin system, and flexibility, offering complete GUI, CLI, and API support.
 
-The `variables` and `objectives` properties are required, while the `constraints` property is optional. Just omit the `constraints` property if there are no constraints for your optimization problem. The names listed in `variables` should come from `variables` of the env specified by the `-e` argument, while the names listed in `objectives` and `constraints` should come from `observations` of that env.
+The primary goal of Badger is to establish a user-friendly interface (GUI, CLI, and API) bridging the gap between users and the machines undergoing optimization. Users only need to define an environment for each machine or simulation, allowing Badger to take charge of tasks such as connecting the problem and optimization algorithm, implementing control logic, visualizing progress, and archiving data.
 
-All optimization runs will be archived in the `$BADGER_ARCHIVE_ROOT` folder that you initially set up when running `badger` the first time.
+Internally, Badger leverages the [Xopt](https://github.com/ChristopherMayes/Xopt/tree/main) optimization library to drive the optimization process. This grants users the advantage of utilizing [a variety of optimization algorithms](https://christophermayes.github.io/Xopt/index.html) available through Xopt.
 
-Several example routine configs can be found in the `examples` folder.
+Badger boasts a range of features designed to enhance your optimization experience:
 
-Below are some example `badger run` commands. They are assumed to run under the parent directory of the `examples` folder (you'll need to clone the `examples` folder from this repo to your computer first). You could run them from any directory, just remember to change the routine config path accordingly.
+- **Plugin System:** Easily add your optimization problem in just a few minutes.
+- **Versatile Modes:** Enjoy full support for three modes: GUI, CLI, and API, allowing you to use Badger according to your preferences.
+- **Efficient Rerun:** With a single click or command, rerun optimization tasks swiftly -- ideal for daily machine operation routines.
+- **History Exploration:** Browse through past runs effortlessly.
+- **Optimal Solution Navigation:** Jump to or set optimal solutions.
+- **State Recovery:** Easily recover machine states after an optimization run.
+- **Constraint Support:** Accommodate both soft and hard constraints.
+- **Data Preservation:** Preserve all raw data for comprehensive analysis.
+- **Advanced Extensions:** Benefit from extensions for sophisticated optimization data analysis and visualization.
 
-#### A simplest run command
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml
-```
-
-#### Run without confirmation
-
-Badger will let you confirm the routine before running it. You could skip the confirmation by adding the `-y` option:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -y
-```
-
-#### Change verbose level
-
-By default, Badger will print out a table contains all the evaluated solutions along the optimization run (with the optimal ones highlighted), you could alter the default behavior by setting the `-v` option.
-
-The default verbose level 2 will print out all the solutions:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -v 2
-```
-
-The table would look like:
-
-```
-|    iter    |     c1     |     x2     |
-----------------------------------------
-|  1         |  3.73      |  2.198     |
-|  2         | -0.9861    |  0.3375    |
-|  3         |  1.888     |  1.729     |
-|  4         |  2.723     |  1.955     |
-|  5         | -1.092     |  0.08923   |
-|  6         |  1.357     |  1.568     |
-|  7         |  4.559     |  2.379     |
-|  8         |  8.757     |  3.14      |
-|  9         |  2.957     |  2.014     |
-|  10        |  0.1204    |  1.105     |
-|  11        |  2.516     |  1.902     |
-|  12        | -0.01194   |  1.043     |
-|  13        |  7.953     |  3.009     |
-|  14        | -1.095     |  0.07362   |
-|  15        | -0.3229    |  0.8815    |
-|  16        | -1.096     |  0.06666   |
-|  17        |  2.662     |  1.94      |
-|  18        |  6.987     |  2.844     |
-|  19        | -0.9734    |  0.3558    |
-|  20        |  3.694     |  2.19      |
-|  21        | -1.032     |  0.2613    |
-|  22        |  2.441     |  1.882     |
-|  23        |  7.042     |  2.853     |
-|  24        |  4.682     |  2.405     |
-|  25        |  0.5964    |  1.302     |
-|  26        |  0.3664    |  1.211     |
-|  27        |  1.966     |  1.751     |
-|  28        |  0.2181    |  1.148     |
-|  29        |  7.954     |  3.009     |
-|  30        | -0.8986    |  0.4488    |
-|  31        | -0.7536    |  0.5885    |
-|  32        |  3.602     |  2.168     |
-|  33        |  0.5527    |  1.286     |
-|  34        | -0.6969    |  0.6349    |
-|  35        | -1.094     |  0.07974   |
-|  36        | -0.8758    |  0.4735    |
-|  37        |  5.995     |  2.664     |
-|  38        |  3.638     |  2.177     |
-|  39        |  2.489     |  1.895     |
-|  40        |  0.8434    |  1.394     |
-|  41        |  0.4919    |  1.262     |
-|  42        | -0.4929    |  0.7792    |
-========================================
-```
+For additional details about Badger and its capabilities, please refer to [Badger's online documentation](https://slac-ml.github.io/Badger/).
 
-Verbose level 1 only prints out the optimal solutions along the run:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -v 1
-```
-
-The table would look like:
-
-```
-|    iter    |     c1     |     x2     |
-----------------------------------------
-|  1         |  1.96      |  1.749     |
-|  2         | -1.037     |  0.2518    |
-|  18        | -1.1       |  0.01942   |
-========================================
-```
-
-Verbose level 0 turns off the printing feature completely:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -v 0
-```
-
-The table would not be printed.
-
-#### Configure algorithm/environment parameters
-
-The following two commands show how to config parameters of the algorithm/environment.
-
-```bash
-badger run -a silly -ap "dimension: 4" -e dumb -c examples/silly_dumb.yaml
-```
-
-```bash
-badger run -a silly -ap "{dimension: 4, max_iter: 10}" -e dumb -c examples/silly_dumb.yaml
-```
-
-#### Run with algorithms provided by extensions
-
-In order to run the following command, you'll need to [set up xopt](https://github.com/ChristopherMayes/Xopt#installing-xopt) on your computer (since the algorithms are provided by xopt).
-
-```bash
-badger run -a cnsga -ap "max_generations: 10" -e TNK -c examples/cnsga_tnk.yaml
-```
-
-#### Save a run
-
-To save a routine to database in `$BADGER_DB_ROOT`, just add the `-s [SAVE_NAME]` option. This command will run and save the routine with a randomly generated two-word name:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -s
-```
-
-The following command will run the routine and save it as `test_routine`:
-
-```bash
-badger run -a silly -e TNK -c examples/silly_tnk.yaml -s test_routine
-```
-
-### Rerun a saved optimization routine
-
-Say we have the routine `test_routine` saved. List all the saved routines by:
-
-```bash
-badger routine
-```
+## Installation
 
-To get the details of some specific routine (say, `test_routine`):
+Using `conda`
 
-```bash
-badger routine test_routine
+```shell
+conda install -c conda-forge badger-opt
 ```
 
-To rerun it, do:
+or `pip`
 
-```bash
-badger routine test_routine -r
+```shell
+pip install badger-opt
 ```
 
-`badger routine` also supports the `-y` and `-v` options, as `badger run` does.
-
-### Configure Badger
-
-If you would like to change some setting that you configured during the first time you run `badger`, you could do so with `badger config`.
-
-List all the configurations:
+Currently Badger only officially support Linux. Badger on MacOS and Windows could be potentially unstable.
 
-```bash
-badger config
-```
+## Run an optimization
 
-To config a property:
+Once Badger is installed, launch the GUI by running the following command in the terminal:
 
 ```bash
-badger config KEY
-```
-
-Where `KEY` is one of the keys in the configuration list.
-
-### Launch the Badger GUI
-
-Badger supports a GUI mode. You can launch the GUI by:
-
-```
 badger -g
 ```
 
-## Development
+Then following [this simple GUI tutorial](https://slac-ml.github.io/Badger/docs/next/getting-started/tutorial_0) to run your first optimizaion in Badger within a couple of minutes!
 
-### Install the Badger core in editable mode
+## Citation
 
-Clone this repo and `cd` to the project root, then install badger in dev mode:
+If you use Badger for your research, please consider adding the following citation to your publications.
 
-```bash
-pip install -e .
 ```
-
-#### Uninstall Badger
-
-To uninstall badger, run the following command under the project root:
-
-```bash
-python setup.py develop -u
+Zhang, Z., et al. "Badger: The missing optimizer in ACR",
+in Proc. IPAC'22, Bangkok. doi:10.18429/JACoW-IPAC2022-TUPOST058
 ```
 
-### Develop algorithm plugins for Badger
-
-Algorithm in Badger is just a function has the following signature:
-
-```
-result = optimize(evaluate, params)
+BibTex entry:
+```bibtex
+@inproceedings{Badger,
+    author       = {Z. Zhang and M. Böse and A.L. Edelen and J.R. Garrahan and Y. Hidaka and C.E. Mayes and S.A. Miskovich and D.F. Ratner and R.J. Roussel and J. Shtalenkova and S. Tomin and G.M. Wang},
+    title        = {{Badger: The Missing Optimizer in ACR}},
+    booktitle    = {Proc. IPAC'22},
+    pages        = {999--1002},
+    eid          = {TUPOST058},
+    language     = {english},
+    keywords     = {interface, controls, GUI, operation, framework},
+    venue        = {Bangkok, Thailand},
+    series       = {International Particle Accelerator Conference},
+    number       = {13},
+    publisher    = {JACoW Publishing, Geneva, Switzerland},
+    month        = {07},
+    year         = {2022},
+    issn         = {2673-5490},
+    isbn         = {978-3-95450-227-1},
+    doi          = {10.18429/JACoW-IPAC2022-TUPOST058},
+    url          = {https://jacow.org/ipac2022/papers/tupost058.pdf},
+}
 ```
 
-Where `evaluate` is an evaluation function for the problem to be optimized, with the signature below:
-
-```
-Y, I, E = evaluate(X)
-```
-
-Here `X`, `Y` are the decision vectors and the objectives, respectively. `I` is the inequality constraints, `E` the equality constraints. `X` and `Y` are 2D arrays, and `I` and `E` are either 2D arrays or `None`, depends on whether the optimization problem has the corresponding constraints or not.
-
-To see an example of a Badger algorithm plugin, please have a look at the algorithms in the [official Badger algo registry](https://github.com/SLAC-ML/Badger-Plugins/tree/master/algorithms).
-
-For now, you could simply create a folder named after your algorithm under the `$BADGER_PLUGIN_ROOT/algorithms` directory, where `$BADGER_PLUGIN_ROOT` is the value for key `BADGER_PLUGIN_ROOT` when you run `badger config`, and put the `__init__.py`, `configs.yaml`, and an optional `README.md` into your algorithm folder.
-
-You can then `badger algo` to see if your algorithm is there.
-
-### Develop environment plugins for Badger
-
-Before developing new environments for Badger, please have a look at the [available environments](https://github.com/SLAC-ML/Badger-Plugins/tree/master/environments) in the official Badger plugins repo.
-
-The existing envs could boost up your new env development process since Badger supports **nested environments**, which means that you could use environments in other environment, to reuse the observations/variables in the existing environments. To see an example of a nested environment, please check out the code of `silly`, `naive`, and `dumb` envs in the official Badger env registry. Note `dumb = silly + naive`.
+## Issues or questions?
 
-For now, you could simply create a folder named after your env under the `$BADGER_PLUGIN_ROOT/environments` directory, where `$BADGER_PLUGIN_ROOT` is the value for key `BADGER_PLUGIN_ROOT` when you run `badger config`, and put the `__init__.py`, `configs.yaml`, and an optional `README.md` into your env folder.
+Please check out current Badger issues [here](https://github.com/slaclab/Badger/issues) before [opening a new one](https://github.com/slaclab/Badger/issues/new/). Alternatively, you are welcome to [shoot us an email](mailto:zhezhang@slac.stanford.edu), or join our [Slack channel](https://join.slack.com/share/enQtNjI3NjY0NDk0MzUxMC0zNThjYTdmNmZlOTA3MzM2NjFjZTVmNDY5MDk4NmQ2NmQxMjE1NjBjN2VhNzYxNjBjYjgxYzMwY2ExZTIxYzkw).
 
-You can then `badger env` to see if your environment is there.
+[^1]: Draws limited inspiration from [Ocelot the optimizer](https://github.com/ocelot-collab/optimizer).
diff --git a/env/environment.yml b/env/environment.yml
new file mode 100644
index 00000000..2e6037e2
--- /dev/null
+++ b/env/environment.yml
@@ -0,0 +1,11 @@
+# conda env create -f environment.yml
+name: badger-dev
+channels:
+  - conda-forge
+  - defaults
+dependencies:
+  - coolname
+  - pillow
+  - requests
+# Dev
+  - pytest
diff --git a/env/requirements.txt b/env/requirements.txt
new file mode 100644
index 00000000..470daf95
--- /dev/null
+++ b/env/requirements.txt
@@ -0,0 +1,8 @@
+# Formatting
+flake8
+zipp
+# Qt related packages after pyqt5 is installed
+pyqtgraph
+qdarkstyle
+pytest-qt
+pytest-mock
diff --git a/windows-dev-requirements.txt b/env/windows-dev-requirements.txt
similarity index 100%
rename from windows-dev-requirements.txt
rename to env/windows-dev-requirements.txt
diff --git a/environment.yml b/environment.yml
deleted file mode 100644
index a3f17cc8..00000000
--- a/environment.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-name: badger-env
-channels:
-  - conda-forge
-  - defaults
-  - pytorch
diff --git a/pics/badger.png b/pics/badger.png
new file mode 100644
index 00000000..183969d3
Binary files /dev/null and b/pics/badger.png differ
diff --git a/pics/main.png b/pics/main.png
new file mode 100644
index 00000000..c2665f0e
Binary files /dev/null and b/pics/main.png differ
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index d1b0e161..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-pandas
-pyyaml
-coolname
-pyqtgraph
-qdarkstyle
-pillow
-requests
-tqdm
-xopt>=2.0.0
-pytest
-pytest-qt
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 2151596b..cebf45b8 100644
--- a/setup.py
+++ b/setup.py
@@ -28,7 +28,8 @@
     extras_require={
         'dev': [
             'pytest',
-            'pytest-qt'
+            'pytest-qt',
+            'pytest-mock'
         ]
     },
     python_requires='>=3.9',
diff --git a/src/badger/__main__.py b/src/badger/__main__.py
index 581906ee..bd33c6a9 100644
--- a/src/badger/__main__.py
+++ b/src/badger/__main__.py
@@ -6,7 +6,7 @@
 from .actions import show_info
 from .actions.doctor import self_check
 from .actions.routine import show_routine
-from .actions.algo import show_algo
+from .actions.generator import show_generator
 from .actions.env import show_env
 from .actions.install import plugin_install
 from .actions.uninstall import plugin_remove
@@ -34,6 +34,8 @@ def main():
     # Parser for the 'doctor' command
     parser_doctor = subparsers.add_parser(
         'doctor', help='Badger status self-check')
+    parser_doctor.add_argument('-r', '--reset', action='store_true',
+                               help='reset Badger to factory settings')
     parser_doctor.set_defaults(func=self_check)
 
     # Parser for the 'routine' command
@@ -49,10 +51,10 @@ def main():
                                 help='verbose level of optimization progress')
     parser_routine.set_defaults(func=show_routine)
 
-    # Parser for the 'algo' command
-    parser_algo = subparsers.add_parser('algo', help='Badger algorithms')
-    parser_algo.add_argument('algo_name', nargs='?', type=str, default=None)
-    parser_algo.set_defaults(func=show_algo)
+    # Parser for the 'generator' command
+    parser_generator = subparsers.add_parser('generator', help='Badger generators')
+    parser_generator.add_argument('generator_name', nargs='?', type=str, default=None)
+    parser_generator.set_defaults(func=show_generator)
 
     # Parser for the 'intf' command
     parser_intf = subparsers.add_parser('intf', help='Badger interfaces')
@@ -64,25 +66,24 @@ def main():
     parser_env.add_argument('env_name', nargs='?', type=str, default=None)
     parser_env.set_defaults(func=show_env)
 
-    # Parser for the 'install' command     
+    # Parser for the 'install' command
     parser_inst = subparsers.add_parser('install', help='Badger install plugin')
     parser_inst.add_argument('plugin_type', nargs='?', type=str, default=None)
     parser_inst.add_argument('plugin_specific', nargs='?', type=str, default=None)
     parser_inst.set_defaults(func=plugin_install)
 
-    # Parser for the 'remove' command     
+    # Parser for the 'remove' command
     parser_remove = subparsers.add_parser('remove', help='Badger remove plugin')
     parser_remove.add_argument('plugin_type', nargs='?', type=str, default=None)
     parser_remove.add_argument('plugin_specific', nargs='?', type=str, default=None)
     parser_remove.set_defaults(func=plugin_remove)
 
-    
     # Parser for the 'run' command
     parser_run = subparsers.add_parser('run', help='run routines')
-    parser_run.add_argument('-a', '--algo', required=True,
-                            help='algorithm to use')
-    parser_run.add_argument('-ap', '--algo_params',
-                            help='parameters for the algorithm')
+    parser_run.add_argument('-a', '--generator', required=True,
+                            help='generator to use')
+    parser_run.add_argument('-ap', '--generator_params',
+                            help='parameters for the generator')
     parser_run.add_argument('-e', '--env', required=True,
                             help='environment to use')
     parser_run.add_argument('-ep', '--env_params',
diff --git a/src/badger/actions/__init__.py b/src/badger/actions/__init__.py
index 5139a217..7ebfc9b1 100644
--- a/src/badger/actions/__init__.py
+++ b/src/badger/actions/__init__.py
@@ -2,7 +2,6 @@
 from .doctor import check_n_config_paths
 from ..utils import yprint
 from ..log import set_log_level
-from ..settings import read_value
 
 
 def show_info(args):
@@ -10,51 +9,40 @@ def show_info(args):
     set_log_level(args.log)
 
     if args.gui:
-        from ..gui.default import launch_gui
-
-        launch_gui()
-    elif args.gui_acr:
-        from ..gui.default import launch_gui
-
-        launch_gui()
-    else:
-        try:
-            from ..factory import BADGER_PLUGIN_ROOT, BADGER_EXTENSIONS
-        except:
-            BADGER_PLUGIN_ROOT = None
-            BADGER_EXTENSIONS = None
-        try:
-            from ..db import BADGER_DB_ROOT
-        except:
-            BADGER_DB_ROOT = None
-        try:
-            from ..logbook import BADGER_LOGBOOK_ROOT
-        except:
-            BADGER_LOGBOOK_ROOT = None
-        try:
-            from ..archive import BADGER_ARCHIVE_ROOT
-        except:
-            BADGER_ARCHIVE_ROOT = None
-
-        info = {
-            'name': 'Badger the optimizer',
-            'version': get_distribution('badger-opt').version,
-            'plugin root': BADGER_PLUGIN_ROOT,
-            'database root': BADGER_DB_ROOT,
-            'logbook root': BADGER_LOGBOOK_ROOT,
-            'archive root': BADGER_ARCHIVE_ROOT,
-            'plugin installation url': read_value('BADGER_PLUGINS_URL')
-        }
-
-        if BADGER_EXTENSIONS:
-            extensions = list(BADGER_EXTENSIONS.keys())
-            if extensions:
-                info['extensions'] = extensions
-
-        yprint(info)
-
-        check_n_config_paths()
-        # print(f'Badger the optimizer')
-        # print('====================')
-        # print(f'version      : {version}')
-        # print(f'plugins root : {BADGER_PLUGIN_ROOT}')
+        if check_n_config_paths():
+            from ..gui.default import launch_gui
+
+            launch_gui()
+
+        return
+
+    if args.gui_acr:
+        if check_n_config_paths():
+            from ..gui.default import launch_gui
+
+            launch_gui()
+
+        return
+
+    if not check_n_config_paths():
+        return
+
+    from ..settings import read_value
+
+    BADGER_PLUGIN_ROOT = read_value("BADGER_PLUGIN_ROOT")
+    BADGER_DB_ROOT = read_value("BADGER_DB_ROOT")
+    BADGER_LOGBOOK_ROOT = read_value("BADGER_LOGBOOK_ROOT")
+    BADGER_ARCHIVE_ROOT = read_value("BADGER_ARCHIVE_ROOT")
+
+    info = {
+        'name': 'Badger the optimizer',
+        'version': get_distribution('badger-opt').version,
+        'plugin root': BADGER_PLUGIN_ROOT,
+        'database root': BADGER_DB_ROOT,
+        'logbook root': BADGER_LOGBOOK_ROOT,
+        'archive root': BADGER_ARCHIVE_ROOT,
+        # 'plugin installation url': read_value('BADGER_PLUGINS_URL')
+    }
+
+    # print()  # put one blank line before the printout
+    yprint(info)
diff --git a/src/badger/actions/doctor.py b/src/badger/actions/doctor.py
index f07a22ec..8b829a0c 100644
--- a/src/badger/actions/doctor.py
+++ b/src/badger/actions/doctor.py
@@ -1,8 +1,30 @@
-from ..settings import read_value, BADGER_PATH_DICT
+from ..settings import read_value, BADGER_PATH_DICT, \
+    reset_settings, mock_settings
 from .config import _config_path_var
 
 
 def self_check(args):
+
+    # Reset Badger
+    if args.reset:
+        while True:
+            _res = input(
+                'Badger will be reset to the factory settings.\n'
+                'Only the configurations and built-in plugins will be reset,\n'
+                'your saved routines and data will not be touched.\n\n'
+                'proceed (y/[n])? ')
+            if _res == 'y':
+                break
+            elif (not _res) or (_res == 'n'):
+                print('Reset cancelled.')
+                return
+            else:
+                print(f'Invalid choice: {_res}')
+
+        reset_settings()
+        print('Badger has been reset to the factory settings.')
+        return
+
     good = check_n_config_paths()
     if good:
         print('Badger is healthy!')
@@ -11,13 +33,52 @@ def self_check(args):
 def check_n_config_paths():
     good = True
 
+    issue_list = []
+
     for pname in BADGER_PATH_DICT.keys():
         if not read_value(pname):
             good = False
-            dname = BADGER_PATH_DICT[pname]['display name']
-            print(f'\n{dname} needs to be configured!\n')
+            # dname = BADGER_PATH_DICT[pname]['display name']
+            # print(f'\n{dname} needs to be configured!')
+
+            issue_list.append(pname)
+
+    if not good:
+        # Initial setup
+        init = True
+        while False:
+            _res = input(
+                'If this is your first time launching Badger, you should initialize it now.\n'
+                'Proceed ([y]/n)? ')
+            if (not _res) or (_res == 'y'):
+                init = True
+                break
+            elif _res == 'n':
+                init = False
+                break
+            else:
+                print(f'Invalid choice: {_res}')
+
+        if init:  # fill in the mock up settings so user can go immediately
+            mock_settings()
+
+            return True
+
+        # Let the users deal with the issues
+        while True:
+            _res = input(
+                f'\nFound {len(issue_list)} issue(s).\n'
+                'Fix the issue(s) now ([y]/n)? ')
+            if (not _res) or (_res == 'y'):
+                break
+            elif _res == 'n':
+                return good
+            else:
+                print(f'Invalid choice: {_res}')
 
+        for pname in issue_list:
             try:
+                print('')
                 _config_path_var(pname)
             except KeyboardInterrupt:
                 pass
diff --git a/src/badger/actions/algo.py b/src/badger/actions/generator.py
similarity index 66%
rename from src/badger/actions/algo.py
rename to src/badger/actions/generator.py
index 02faa563..e1b20d36 100644
--- a/src/badger/actions/algo.py
+++ b/src/badger/actions/generator.py
@@ -3,19 +3,19 @@
 from ..utils import yprint
 
 
-def show_algo(args):
+def show_generator(args):
     try:
-        from ..factory import list_algo, get_algo
+        from ..factory import list_generators, get_generator
     except Exception as e:
         logger.error(e)
         return
 
-    if args.algo_name is None:
-        yprint(list_algo())
+    if args.generator_name is None:
+        yprint(list_generators())
         return
 
     try:
-        _, configs = get_algo(args.algo_name)
+        _, configs = get_generator(args.generator_name)
         yprint(configs)
     except Exception as e:
         logger.error(e)
diff --git a/src/badger/actions/install.py b/src/badger/actions/install.py
index ff219efe..d9824cec 100644
--- a/src/badger/actions/install.py
+++ b/src/badger/actions/install.py
@@ -23,14 +23,14 @@ def plugin_install(args):
     from conda.cli.python_api import run_command, Commands
 
     hist = {
-        'algo': 'algorithms',
+        'generator': 'generators',
         'env': 'environments',
         'ext': 'extensions',
         'intf': 'interfaces'
     }
 
     identify = {
-        'optimize': 'algorithms',
+        'optimize': 'generators',
         'Environment': 'environments',
         'Extension': 'extensions',
         'Interface': 'interfaces'
@@ -41,7 +41,7 @@ def plugin_install(args):
         return
 
     if args.plugin_type != 'local' and args.plugin_type not in hist:
-        print(f"{args.plugin_type} is an invalid option. Choose one of the following:  algo, env, ext, intf, local")
+        print(f"{args.plugin_type} is an invalid option. Choose one of the following:  generator, env, ext, intf, local")
         return
 
     plugins_url = read_value('BADGER_PLUGINS_URL')
diff --git a/src/badger/actions/routine.py b/src/badger/actions/routine.py
index 0cc5329b..3d5df363 100644
--- a/src/badger/actions/routine.py
+++ b/src/badger/actions/routine.py
@@ -1,6 +1,8 @@
 import logging
 logger = logging.getLogger(__name__)
-from ..utils import range_to_str, yprint
+import pandas as pd
+import yaml
+from ..utils import yprint
 
 
 def show_routine(args):
@@ -31,8 +33,19 @@ def show_routine(args):
 
     # Print the routine
     if not args.run:
-        routine['config']['variables'] = range_to_str(routine['config']['variables'])
-        yprint(routine)
+        info = yaml.safe_load(routine.yaml())
+        output = {}
+        output['name'] = info['name']
+        output['environment'] = info['environment']
+        output['algorithm'] = info['generator']
+        output['vocs'] = info['vocs']
+        output['initial_points'] = pd.DataFrame(
+            info['initial_points']).to_dict('list')
+        output['critical_constraint_names'] = info['critical_constraint_names']
+        output['tags'] = info['tags']
+        output['script'] = info['script']
+
+        yprint(output)
         return
 
     run_n_archive(routine, args.yes, False, args.verbose)
diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py
index 5e1dba87..a7d32e98 100644
--- a/src/badger/actions/run.py
+++ b/src/badger/actions/run.py
@@ -4,40 +4,27 @@
 import sys
 import time
 import signal
-import pandas as pd
+from pandas import DataFrame
 from coolname import generate_slug
 from ..utils import load_config, merge_params
-from ..utils import config_list_to_dict, curr_ts, ts_to_str
+from ..utils import config_list_to_dict, curr_ts
 from ..core import run_routine as run
-from ..core import normalize_routine
+from ..routine import Routine
 from ..settings import read_value
 from ..errors import BadgerRunTerminatedError
 
 
-def run_n_archive(routine, yes=False, save=False, verbose=2,
-                  fmt='lcls-log-full', sleep=0, flush_prompt=False):
+def run_n_archive(routine: Routine, yes=False, save=False, verbose=2,
+                  sleep=0, flush_prompt=False):
     try:
         from ..archive import archive_run
     except Exception as e:
         logger.error(e)
         return
 
-    var_names = [next(iter(d)) for d in routine['config']['variables']]
-    obj_names = [next(iter(d)) for d in routine['config']['objectives']]
-    if routine['config']['constraints']:
-        con_names = [next(iter(d)) for d in routine['config']['constraints']]
-    else:
-        con_names = []
-    try:
-        sta_names = routine['config']['states'] or []
-    except KeyError:  # this would happen when rerun an old version routine
-        sta_names = []
-    # Store solutions in a list to avoid global var def
-    solutions = []
     # Store system states and other stuff
     storage = {
         'states': None,
-        'env': None,
         'ts_last_dump': None,
         'paused': False,
     }
@@ -52,7 +39,10 @@ def handler(*args):
 
     signal.signal(signal.SIGINT, handler)
 
-    def before_evaluate(vars):
+    def check_run_status():
+        return 0
+
+    def before_evaluate(candidates: DataFrame):
         if storage['paused']:
             res = input('Optimization paused. Press Enter to resume or Ctrl/Cmd + C to terminate: ')
             while res != '':
@@ -63,29 +53,27 @@ def before_evaluate(vars):
                 sys.stdout.write('\033[F')
         storage['paused'] = False
 
-    def after_evaluate(vars, obses, cons, stas):
+    def after_evaluate(data: DataFrame):
         # vars: ndarray
         # obses: ndarray
         # cons: ndarray
         # stas: list
         ts = curr_ts()
         ts_float = ts.timestamp()
-        solution = [ts_float, ts_to_str(ts, fmt)] + list(obses) + list(cons) + list(vars) + stas
-        solutions.append(solution)
 
         # Try dump the run data and interface log to the disk
         dump_period = float(read_value('BADGER_DATA_DUMP_PERIOD'))
         ts_last_dump = storage['ts_last_dump']
         if (ts_last_dump is None) or (ts_float - ts_last_dump > dump_period):
             storage['ts_last_dump'] = ts_float
-            df = pd.DataFrame(solutions, columns=['timestamp_raw', 'timestamp'] + obj_names + con_names + var_names + sta_names)
-            _run = archive_run(routine, df, storage['states'])
+            _run = archive_run(routine, storage['states'])
             # Try dump the interface logs
             try:
                 path = _run['path']
                 filename = _run['filename'][:-4] + 'pickle'
-                storage['env'].interface.dump_recording(os.path.join(path, filename))
-            except:
+                routine.environment.interface.dump_recording(
+                    os.path.join(path, filename))
+            except Exception:
                 pass
 
         # take a break to let the outside signal to change the status
@@ -94,79 +82,72 @@ def after_evaluate(vars, obses, cons, stas):
     def states_ready(states):
         storage['states'] = states
 
-    def env_ready(env):
-        storage['env'] = env
-
     try:
-        run(routine, yes, save, verbose,
-            before_evaluate=before_evaluate, after_evaluate=after_evaluate,
-            states_ready=states_ready, env_ready=env_ready)
+        run(routine,
+            active_callback=check_run_status,
+            generate_callback=before_evaluate,
+            evaluate_callback=after_evaluate,
+            states_callback=states_ready)
+    except BadgerRunTerminatedError as e:
+        logger.info(e)
     except Exception as e:
-        if str(e) == 'Optimization run has been terminated!':
-            logger.info(e)
-        else:
-            logger.error(e)
-
-    if solutions:  # only save the run when at least one solution has been evaluated
-        df = pd.DataFrame(solutions, columns=['timestamp_raw', 'timestamp'] + obj_names + con_names + var_names + sta_names)
-        _run = archive_run(routine, df, storage['states'])
+        logger.error(e)
+
+    # Save the run when at least one solution has been evaluated
+    if len(routine.data):
+        _run = archive_run(routine, storage['states'])
         # Try dump the interface logs
         try:
             path = _run['path']
             filename = _run['filename'][:-4] + 'pickle'
-            storage['env'].interface.stop_recording(os.path.join(path, filename))
-        except:
+            routine.environment.interface.stop_recording(
+                os.path.join(path, filename))
+        except Exception:
             pass
 
 
 def run_routine(args):
-    try:
-        from ..factory import get_algo, get_env
-    except Exception as e:
-        logger.error(e)
-        return
-
-    try:
-        # Get env params
-        _, configs_env = get_env(args.env)
-
-        # Get algo params
-        _, configs_algo = get_algo(args.algo)
-
-        # Normalize the algo and env params
-        params_env = load_config(args.env_params)
-        params_algo = load_config(args.algo_params)
-    except Exception as e:
-        logger.error(e)
-        return
-    params_env = merge_params(configs_env['params'], params_env)
-    params_algo = merge_params(configs_algo['params'], params_algo)
-
-    # Load routine configs
-    try:
-        configs_routine = load_config(args.config)
-    except Exception as e:
-        logger.error(e)
-        return
-
-    # Compose the routine
-    routine = {
-        'name': args.save or generate_slug(2),
-        'algo': args.algo,
-        'env': args.env,
-        'algo_params': params_algo,
-        'env_params': params_env,
-        # env_vranges is an additional info for the normalization
-        # Will be removed after the normalization
-        'env_vranges': config_list_to_dict(configs_env['variables']),
-        'config': configs_routine,
-    }
-
-    # Sanity check and config normalization
-    try:
-        routine = normalize_routine(routine)
-    except Exception as e:
-        logger.error(e)
-        return
-
-    run_n_archive(routine, args.yes, args.save, args.verbose)
+    pass
+    # try:
+    #     from ..factory import get_algo, get_env
+    # except Exception as e:
+    #     logger.error(e)
+    #     return
+
+    # try:
+    #     # Get env params
+    #     _, configs_env = get_env(args.env)
+
+    #     # Get algo params
+    #     _, configs_algo = get_algo(args.algo)
+
+    #     # Normalize the algo and env params
+    #     params_env = load_config(args.env_params)
+    #     params_algo = load_config(args.algo_params)
+    # except Exception as e:
+    #     logger.error(e)
+    #     return
+    # params_env = merge_params(configs_env['params'], params_env)
+    # params_algo = merge_params(configs_algo['params'], params_algo)
+
+    # # Load routine configs
+    # try:
+    #     configs_routine = load_config(args.config)
+    # except Exception as e:
+    #     logger.error(e)
+    #     return
+
+    # # Compose the routine
+    # routine = {
+    #     'name': args.save or generate_slug(2),
+    #     'algo': args.algo,
+    #     'env': args.env,
+    #     'algo_params': params_algo,
+    #     'env_params': params_env,
+    #     # env_vranges is an additional info for the normalization
+    #     # Will be removed after the normalization
+    #     'env_vranges': config_list_to_dict(configs_env['variables']),
+    #     'config': configs_routine,
+    # }
+
+    # run_n_archive(routine, args.yes, args.save, args.verbose)
diff --git a/src/badger/actions/uninstall.py b/src/badger/actions/uninstall.py
index cb623d6f..b981c47b 100644
--- a/src/badger/actions/uninstall.py
+++ b/src/badger/actions/uninstall.py
@@ -12,7 +12,7 @@ def plugin_remove(args):
 		return
 
 	hist = {
-		'algo': 'algorithms',
+		'generator': 'generators',
 		'env': 'environments',
 		'ext': 'extensions',
 		'intf': 'interfaces'
diff --git a/src/badger/archive.py b/src/badger/archive.py
index 9bad75ce..d56450df 100644
--- a/src/badger/archive.py
+++ b/src/badger/archive.py
@@ -2,8 +2,9 @@
 import logging
 logger = logging.getLogger(__name__)
 from .db import save_run, remove_run_by_filename
-from .utils import ts_float_to_str, ystring, load_config
+from .utils import ts_float_to_str
 from .settings import read_value
+from .routine import Routine
 from .errors import BadgerConfigError
 
 
@@ -17,12 +18,12 @@
         f'Badger run root {BADGER_ARCHIVE_ROOT} created')
 
 
-def archive_run(routine, data, states=None):
-    # routine: dict
-    # data: pandas dataframe
+def archive_run(routine, states=None):
+    # routine: Routine
 
+    data = routine.sorted_data
     data_dict = data.to_dict('list')
-    ts_float = data_dict['timestamp_raw'][0]  # time of the first evaluated point
+    ts_float = data_dict['timestamp'][0]  # time of the first evaluated point
     suffix = ts_float_to_str(ts_float, "lcls-fname")
     tokens = suffix.split('-')
     first_level = tokens[0]
@@ -42,8 +43,7 @@ def archive_run(routine, data, states=None):
         run['system_states'] = states
 
     os.makedirs(path, exist_ok=True)
-    with open(os.path.join(path, fname), 'w') as f:
-        f.write(ystring(run))
+    routine.dump(os.path.join(path, fname))
 
     # Temporarily add path information
     # Do not save this info in database or on disk
@@ -84,7 +84,15 @@ def load_run(run_fname):
     second_level = f'{tokens[1]}-{tokens[2]}'
     third_level = f'{tokens[1]}-{tokens[2]}-{tokens[3]}'
 
-    return load_config(os.path.join(BADGER_ARCHIVE_ROOT, first_level, second_level, third_level, run_fname))
+    filename = os.path.join(BADGER_ARCHIVE_ROOT,
+                            first_level,
+                            second_level,
+                            third_level,
+                            run_fname)
+
+    routine = Routine.from_file(filename)
+
+    return routine
 
 
 def delete_run(run_fname):
@@ -96,4 +104,15 @@ def delete_run(run_fname):
     # Remove record from the database
     remove_run_by_filename(run_fname)
 
-    return os.remove(os.path.join(BADGER_ARCHIVE_ROOT, first_level, second_level, third_level, run_fname))
+    prefix = os.path.join(BADGER_ARCHIVE_ROOT, first_level,
+                          second_level, third_level)
+
+    # Try remove the pickle file (could exist or not)
+    pickle_fname = os.path.splitext(run_fname)[0] + '.pickle'
+    try:
+        os.remove(os.path.join(prefix, pickle_fname))
+    except FileNotFoundError:
+        pass
+
+    # Remove the yaml data file
+    os.remove(os.path.join(prefix, run_fname))
diff --git a/src/badger/built_in_plugins/environments/sphere_2d/README.md b/src/badger/built_in_plugins/environments/sphere_2d/README.md
new file mode 100644
index 00000000..fdbffe9f
--- /dev/null
+++ b/src/badger/built_in_plugins/environments/sphere_2d/README.md
@@ -0,0 +1,5 @@
+# 2D Sphere Test Environment for Badger
+
+## Prerequisites
+
+## Usage
diff --git a/src/badger/built_in_plugins/environments/sphere_2d/__init__.py b/src/badger/built_in_plugins/environments/sphere_2d/__init__.py
new file mode 100644
index 00000000..8e8caf00
--- /dev/null
+++ b/src/badger/built_in_plugins/environments/sphere_2d/__init__.py
@@ -0,0 +1,36 @@
+from badger import environment
+
+
+class Environment(environment.Environment):
+
+    name = 'sphere_2d'
+    variables = {
+        'x0': [-1, 1],
+        'x1': [-1, 1],
+    }
+    observables = ['f']
+
+    _variables = {
+        'x0': 0.0,
+        'x1': 0.0,
+    }
+    _observations = {
+        'f': 0.0,
+    }
+
+    def get_variables(self, variable_names):
+        variable_outputs = {v: self._variables[v] for v in variable_names}
+
+        return variable_outputs
+
+    def set_variables(self, variable_inputs: dict[str, float]):
+        for var, x in variable_inputs.items():
+            self._variables[var] = x
+
+        # Filling up the observations
+        f = self._variables['x0'] ** 2 + self._variables['x1'] ** 2
+
+        self._observations['f'] = f
+
+    def get_observables(self, observable_names):
+        return {k: self._observations[k] for k in observable_names}
diff --git a/src/badger/built_in_plugins/environments/sphere_2d/configs.yaml b/src/badger/built_in_plugins/environments/sphere_2d/configs.yaml
new file mode 100644
index 00000000..f02205e3
--- /dev/null
+++ b/src/badger/built_in_plugins/environments/sphere_2d/configs.yaml
@@ -0,0 +1,6 @@
+---
+name: sphere_2d
+description: "2D sphere test environment"
+version: "0.1"
+dependencies:
+  - badger-opt
diff --git a/src/badger/built_in_plugins/interfaces/default/README.md b/src/badger/built_in_plugins/interfaces/default/README.md
new file mode 100644
index 00000000..e7c560b4
--- /dev/null
+++ b/src/badger/built_in_plugins/interfaces/default/README.md
@@ -0,0 +1,5 @@
+# Default Interface for Badger
+
+## Prerequisites
+
+## Usage
diff --git a/src/badger/built_in_plugins/interfaces/default/__init__.py b/src/badger/built_in_plugins/interfaces/default/__init__.py
new file mode 100644
index 00000000..7c0d18b0
--- /dev/null
+++ b/src/badger/built_in_plugins/interfaces/default/__init__.py
@@ -0,0 +1,32 @@
+from badger import interface
+
+
+class Interface(interface.Interface):
+
+    name = 'default'
+    # If params not specified, it would be an empty dict
+
+    # Private variables
+    _states: dict
+
+    def __init__(self, **data):
+        super().__init__(**data)
+
+        self._states = {}
+
+    def get_values(self, channel_names):
+        channel_outputs = {}
+
+        for channel in channel_names:
+            try:
+                value = self._states[channel]
+            except KeyError:
+                self._states[channel] = value = 0
+
+            channel_outputs[channel] = value
+
+        return channel_outputs
+
+    def set_values(self, channel_inputs):
+        for channel, value in channel_inputs.items():
+            self._states[channel] = value
diff --git a/src/badger/built_in_plugins/interfaces/default/configs.yaml b/src/badger/built_in_plugins/interfaces/default/configs.yaml
new file mode 100644
index 00000000..49c7516b
--- /dev/null
+++ b/src/badger/built_in_plugins/interfaces/default/configs.yaml
@@ -0,0 +1,5 @@
+---
+name: default
+version: "0.1"
+dependencies:
+  - badger-opt
diff --git a/src/badger/core.py b/src/badger/core.py
index a88f79b7..55805aa2 100644
--- a/src/badger/core.py
+++ b/src/badger/core.py
@@ -1,577 +1,19 @@
-import logging
 import time
-
-from operator import itemgetter
 from typing import Callable
 
-import numpy as np
-
 from pandas import concat, DataFrame
-from pydantic import BaseModel, ConfigDict
-from xopt import Generator
 
-from .environment import Environment
-from .errors import (
-    BadgerDBError,
-    BadgerNotImplementedError,
+from badger.errors import (
     BadgerRunTerminatedError,
-    VariableRangeError,
 )
-from .utils import (
+from badger.routine import Routine
+from badger.logger import _get_default_logger
+from badger.logger.event import Events
+from badger.utils import (
     curr_ts_to_str,
-    denorm,
     dump_state,
-    merge_params,
-    norm,
-    ParetoFront,
-    parse_rule,
-    range_to_str,
-    yprint,
 )
 
-logger = logging.getLogger(__name__)
-
-
-def process_raw(raw, rule):
-    # filter = rule['filter']
-    reducer = rule["reducer"]
-
-    if reducer == "percentile_80":
-        return np.percentile(raw, 80)
-    elif reducer == "mean":
-        return np.mean(raw)
-    elif reducer == "median":
-        return np.median(raw)
-
-
-def normalize_routine(routine):
-    # Sanity check and config normalization
-    #
-    # routine
-    #  name
-    #  algo
-    #  env
-    #  algo_params
-    #  env_params
-    #  env_vranges: use for vars normalization in config
-    #  config: change the var ranges, the obj rules, and the constraints
-    env_vranges = routine["env_vranges"]
-    config = routine["config"]
-
-    # Normalize the variables
-    for i, var in enumerate(config["variables"]):
-        if type(var) is str:
-            config["variables"][i] = _dict = {}
-            _dict[var] = env_vranges[var]
-        else:
-            var_name = next(iter(var))  # get the only (first) key in the dict
-            vrange_vocs = var[var_name]
-            vrange_default = env_vranges[var_name]
-
-            if vrange_vocs is None:
-                var[var_name] = vrange_default
-                continue
-
-            if vrange_vocs[0] < vrange_default[0]:
-                logger.warning(
-                    f"variable {var_name}: lower limit {vrange_vocs[0]} "
-                    + "exceeds the bound, "
-                    + f"set to the lower bound {vrange_default[0]}"
-                )
-                lb = vrange_default[0]
-            else:
-                lb = vrange_vocs[0]
-            if vrange_vocs[1] > vrange_default[1]:
-                logger.warning(
-                    f"variable {var_name}: upper limit {vrange_vocs[1]} "
-                    + "exceeds the bound, "
-                    + f"set to the upper bound {vrange_default[1]}"
-                )
-                ub = vrange_default[1]
-            else:
-                ub = vrange_vocs[1]
-
-            if lb >= ub:  # TODO: add logic to deal with the == condition
-                raise VariableRangeError(
-                    f"variable {var_name}: lower limit {lb} must be "
-                    + f"lower than the upper limit {ub}!"
-                )
-            var[var_name] = [lb, ub]
-
-    # Normalize the objectives
-    for i, obj in enumerate(config["objectives"]):
-        if type(obj) is str:
-            config["objectives"][i] = _dict = {}
-            _dict[obj] = "MINIMIZE"
-        else:
-            obj_name = next(iter(obj))
-            if obj[obj_name] is None:
-                obj[obj_name] = "MINIMIZE"
-
-    # Normalize the constraints
-    try:
-        _ = config["constraints"]
-        if not _:  # empty list
-            config["constraints"] = None
-    except KeyError:
-        config["constraints"] = None
-
-    # Normalize the states
-    try:
-        _ = config["states"]
-        if not _:  # empty list
-            config["states"] = None
-    except KeyError:
-        config["states"] = None
-
-    # Normalize the domain scaling
-    try:
-        _ = config["domain_scaling"]
-        if not _:  # empty dict
-            config["domain_scaling"] = None
-    except KeyError:
-        config["domain_scaling"] = None
-
-    # Remove the additional info
-    del routine["env_vranges"]
-
-    return routine
-
-
-def run_routine(
-    routine,
-    skip_review=False,
-    save=None,
-    verbose=2,
-    before_evaluate=None,
-    after_evaluate=None,
-    env_ready=None,
-    pf_ready=None,
-    states_ready=None,
-):
-    # Review the routine
-    if not skip_review:
-        print("Please review the routine to be run:\n")
-
-        routine_configs_var = routine["config"]["variables"]
-        routine["config"]["variables"] = range_to_str(routine_configs_var)
-        print("=== Optimization Routine ===")
-        yprint(routine)
-        print("")
-
-        while True:
-            res = input("Proceed ([y]/n)? ")
-            if res == "n":
-                return
-            elif (not res) or (res == "y"):
-                break
-            else:
-                print(f"Invalid choice: {res}")
-
-        routine["config"]["variables"] = routine_configs_var
-
-    # Save routine if specified
-    if save:
-        import sqlite3
-
-        from .db import save_routine
-
-        try:
-            save_routine(routine)
-        except sqlite3.IntegrityError:
-            raise BadgerDBError(
-                f'Routine {routine["name"]} already existed in the database! '
-                + "Please choose another name."
-            )
-
-    # Set up and run the optimization
-    from .factory import get_algo, get_env
-
-    # Instantiate the environment
-    Environment, configs_env = get_env(routine["env"])
-    _configs_env = merge_params(configs_env, {"params": routine["env_params"]})
-
-    env = instantiate_env(Environment, _configs_env)
-
-    # Start recording the opt logs
-    if env.interface:
-        env.interface.start_recording()
-
-    if env_ready:
-        env_ready(env)
-
-    optimize, configs_algo = get_algo(routine["algo"])
-
-    from .logger import _get_default_logger
-    from .logger.event import Events
-
-    # log the optimization progress
-    opt_logger = _get_default_logger(verbose)
-    var_names = [next(iter(d)) for d in routine["config"]["variables"]]
-    vranges = np.array([d[next(iter(d))] for d
-                        in routine["config"]["variables"]])
-    obj_names = [next(iter(d)) for d in routine["config"]["objectives"]]
-    rules = [d[next(iter(d))] for d in routine["config"]["objectives"]]
-    directions = [parse_rule(rule)["direction"] for rule in rules]
-    pf = ParetoFront(directions)
-    if pf_ready:
-        pf_ready(pf)
-    if routine["config"]["constraints"]:
-        con_names = [next(iter(d)) for d in routine["config"]["constraints"]]
-        thresholds = [d[next(iter(d))] for d
-                      in routine["config"]["constraints"]]
-    else:
-        con_names = []
-        thresholds = []
-    try:
-        sta_names = routine["config"]["states"] or []
-    except KeyError:  # this would happen when rerun an old version routine
-        sta_names = []
-
-    # Domain scaling for safety
-    try:
-        configs_scaling = routine["config"]["domain_scaling"]
-    except KeyError:  # this would happen when rerun an old version routine
-        configs_scaling = None
-    scaling = get_scaling_func(configs_scaling)
-
-    info = {"count": -1}
-    # Make a normalized evaluate function
-
-    def evaluate(X):
-        Y = []  # objectives
-        IE = []  # inequality constraints
-        E = []  # equality constraints
-        Xo = []  # normalized readback of variables
-
-        # Return current state if X is None
-        # Do not do the evaluation due to possible high cost
-        if X is None:
-            var_dict = env._get_variables(var_names)
-            _x = np.array([var_dict[v] for v in var_names])
-            x = norm(_x, vranges[:, 0], vranges[:, 1])
-            X = x.reshape(1, -1)
-            return None, None, None, X
-
-        # Perform domain scaling to avoid boundary violations
-        X = scaling(X)
-        # Double Check if bounds are violated
-        if np.max(X) > 1 or np.min(X) < 0:
-            logger.warning(
-                "proposed trial solution exceeds the bounds, "
-                + "solution has been clipped at bounds!"
-            )
-            X = np.clip(X, 0, 1)
-
-        for x in X:
-            _x = denorm(x, vranges[:, 0], vranges[:, 1])
-
-            # Use unsafe version to support temp vars
-            # We have to trust the users...
-            var_inputs = dict(zip(var_names, _x))
-            env._set_variables(var_inputs)
-            env.variables_changed(var_inputs)
-
-            var_dict = env._get_variables(var_names)
-            _xo = np.array([var_dict[v] for v in var_names], dtype=np.float64)
-            xo = norm(_xo, vranges[:, 0], vranges[:, 1])
-            Xo.append(xo)
-
-            # Return the readback rather than the values to be set
-            if before_evaluate:
-                before_evaluate(_xo)
-
-            # Deal with objectives
-            obses = []
-            obses_raw = []
-            for i, obj_name in enumerate(obj_names):
-                rule = rules[i]
-                rule_dict = parse_rule(rule)
-                obs_raw = env._get_observables([obj_name])[obj_name]
-                try:
-                    obs = float(obs_raw)
-                except Exception:  # obs_raw is a list
-                    obs = process_raw(obs_raw, rule_dict)
-                if rule_dict["direction"] == "MAXIMIZE":
-                    obses.append(-obs)
-                else:
-                    obses.append(obs)
-                obses_raw.append(obs)
-            Y.append(obses)
-            obses_raw = np.array(obses_raw, dtype=np.float64)
-
-            # Deal with constraints
-            # TODO: Check overlapping between objs and cons
-            cons_i = []
-            cons_e = []
-            cons_raw = []
-            for i, con_name in enumerate(con_names):
-                relation, thres = thresholds[i][:2]
-                con = float(env._get_observables([con_name])[con_name])
-                if relation == "GREATER_THAN":
-                    cons_i.append(con - thres)
-                elif relation == "LESS_THAN":
-                    cons_i.append(thres - con)
-                else:
-                    cons_e.append(con - thres)
-                cons_raw.append(con)
-            if cons_i:
-                IE.append(cons_i)
-            if cons_e:
-                E.append(cons_e)
-            cons_raw = np.array(cons_raw, dtype=np.float64)
-
-            # Deal with states
-            states = []
-            for sta_name in sta_names:
-                # A state could be an observation or a variable
-                try:
-                    sta = env._get_observables([sta_name])[sta_name]
-                except Exception:
-                    sta = env._get_variables([sta_name])[sta_name]
-                states.append(sta)
-
-            info["count"] += 1
-            _idx_x = np.insert(_xo, 0, info["count"])  # keep the idx info
-
-            is_optimal = not pf.is_dominated((_idx_x, obses_raw))
-            solution = (
-                _xo,
-                obses_raw,
-                cons_raw,
-                states,
-                is_optimal,
-                var_names,
-                obj_names,
-                con_names,
-                sta_names,
-            )
-            opt_logger.update(Events.OPTIMIZATION_STEP, solution)
-
-            if after_evaluate:
-                after_evaluate(_xo, obses_raw, cons_raw, states)
-
-        Y = np.array(Y)
-        if IE:
-            IE = np.array(IE)
-        else:
-            IE = None
-        if E:
-            E = np.array(E)
-        else:
-            E = None
-        Xo = np.array(Xo)
-
-        return Y, IE, E, Xo
-
-    # Start the optimization
-    print("")
-    solution = (
-        None,
-        None,
-        None,
-        None,
-        None,
-        var_names,
-        obj_names,
-        con_names,
-        sta_names,
-    )
-    opt_logger.update(Events.OPTIMIZATION_START, solution)
-    try:
-        if not callable(optimize):  # doing optimization through extensions
-            configs = {
-                "routine_configs": routine["config"],
-                "algo_configs": merge_params(
-                    configs_algo, {"params": routine["algo_params"]}
-                ),
-                "env_configs": _configs_env,
-            }
-            optimize = optimize.optimize
-        else:
-            configs = routine["algo_params"]
-
-        # Save system states if applicable
-        states = env.get_system_states()
-        if states_ready and (states is not None):
-            states_ready(states)
-
-        optimize(evaluate, configs)
-    except Exception as e:
-        opt_logger.update(Events.OPTIMIZATION_END, solution)
-        raise e
-    opt_logger.update(Events.OPTIMIZATION_END, solution)
-
-
-def instantiate_env(env_class, configs, manager=None):
-    # Have to put here to avoid circular dependencies
-    from .factory import get_intf
-
-    # Configure interface
-    # TODO: figure out the correct logic
-    # It seems that the interface should be given rather than
-    # initialized here
-    try:
-        intf_name = configs["interface"][0]
-    except KeyError:
-        intf_name = None
-    except Exception as e:
-        logger.warning(e)
-        intf_name = None
-
-    if intf_name is not None:
-        if manager is None:
-            Interface, _ = get_intf(intf_name)
-            intf = Interface()
-        else:
-            intf = manager.Interface()
-    else:
-        intf = None
-
-    env = env_class(interface=intf, **configs["params"])
-
-    return env
-
-
-# The following functions are related to domain scaling
-# TODO: consider combine them into a class and make it extensible
-def list_scaling_func():
-    return ["semi-linear", "sinusoid", "sigmoid"]
-
-
-def get_scaling_default_params(name):
-    if name == "semi-linear":
-        default_params = {
-            "center": 0.5,
-            "range": 1,
-        }
-    elif name == "sinusoid":
-        default_params = {
-            "center": 0.5,
-            "period": 2,
-        }
-    elif name == "sigmoid":
-        default_params = {
-            "center": 0.5,
-            "lambda": 8,
-        }
-    else:
-        raise BadgerNotImplementedError(
-            f"scaling function {name} is not supported")
-
-    return default_params
-
-
-def get_scaling_func(configs):
-    if not configs:  # fallback to default
-        configs = {"func": "semi-linear"}
-
-    name = configs["func"]
-    params = configs.copy()
-    del params["func"]
-
-    default_params = get_scaling_default_params(name)
-    params = merge_params(default_params, params)
-
-    if name == "semi-linear":
-        center, range = itemgetter("center", "range")(params)
-
-        def func(X):
-            return np.clip((X - center) / range + 0.5, 0, 1)
-
-    elif name == "sinusoid":
-        center, period = itemgetter("center", "period")(params)
-
-        def func(X):
-            return 0.5 * np.sin(2 * np.pi / period * (X - center)) + 0.5
-
-    elif name == "sigmoid":
-        center, lamb = itemgetter("center", "lambda")(params)
-
-        def func(X):
-            return 1 / (1 + np.exp(-lamb * (X - center)))
-
-    # TODO: consider remove this branch since it's useless
-    else:
-        raise BadgerNotImplementedError(
-            f"scaling function {name} is not supported")
-
-    return func
-
-
-class Routine(BaseModel):
-    model_config = ConfigDict(arbitrary_types_allowed=True)
-
-    environment: Environment
-    generator: Generator
-    initial_points: DataFrame
-
-    # convenience properties
-    @property
-    def vocs(self):
-        """
-        A property that returns the vocs of the generator attribute.
-
-        Returns:
-            self.generator.vocs : VOCS
-        """
-        return self.generator.vocs
-
-
-def evaluate_points(
-    points: DataFrame, routine: Routine, callback: Callable
-) -> DataFrame:
-    """
-    Evaluates points using the environment
-
-    Parameters
-    ----------
-    points : DataFrame
-    routine : Routine
-    callback : Callable
-
-    Returns
-    -------
-    evaluated_points : DataFrame
-    """
-
-    env = routine.environment
-    vocs = routine.vocs
-
-    obj_list = []
-    for i in range(points.shape[0]):
-        env._set_variables(points.iloc[i].to_dict())  # point is a series!
-        observable_names = vocs.objective_names + vocs.constraint_names + \
-            vocs.observable_names
-        obj = env._get_observables(observable_names)
-        obj_df = DataFrame(obj, index=[0])
-        obj_list.append(obj_df)
-
-        # Have to call the callback after each evaluation
-        # since we need the time information
-        # Note: point here is a dataframe!
-        point_eval = concat([points.iloc[i:i + 1].reset_index(drop=True),
-                             obj_df], axis=1)
-        if callback:
-            callback(point_eval)
-
-    points_obj = concat(obj_list, axis=0).reset_index(drop=True)
-    points_eval = concat([points, points_obj], axis=1)
-
-    return points_eval
-
-
-def add_to_pf(idx: int, candidate: DataFrame, result: DataFrame,
-              pf: ParetoFront) -> None:
-    n_sol, n_var = candidate.shape
-    for i in range(n_sol):  # loop through each solution
-        inputs = candidate.iloc[i].values
-        inputs = np.insert(inputs, 0, idx + i)
-        outputs = result.iloc[i][n_var:].values[:]  # copy to avoid troubles
-        pf.is_dominated((inputs, outputs))
-
-    return n_sol  # number of points inserted
-
 
 def check_run_status(active_callback):
     while True:
@@ -585,14 +27,39 @@ def check_run_status(active_callback):
             break
 
 
-def run_routine_xopt(
-    routine: Routine,
-    active_callback: Callable,
-    generate_callback: Callable,
-    evaluate_callback: Callable,
-    pf_callback: Callable,
-    states_callback: Callable,
-    dump_file_callback: Callable = None,
+def convert_to_solution(result: DataFrame, routine: Routine):
+    vocs = routine.vocs
+    try:
+        best_idx, _ = vocs.select_best(routine.sorted_data, n=1)
+        if best_idx != len(routine.data) - 1:
+            is_optimal = False
+        else:
+            is_optimal = True
+    except NotImplementedError:
+        is_optimal = False  # disable the optimal highlight for MO problems
+
+    vars = list(result[vocs.variable_names].to_numpy()[0])
+    objs = list(result[vocs.objective_names].to_numpy()[0])
+    cons = list(result[vocs.constraint_names].to_numpy()[0])
+    stas = list(result[vocs.observable_names].to_numpy()[0])
+
+    solution = (vars, objs, cons, stas, is_optimal,
+                vocs.variable_names,
+                vocs.objective_names,
+                vocs.constraint_names,
+                vocs.observable_names)
+
+    return solution
+
+
+def run_routine(
+        routine: Routine,
+        active_callback: Callable,
+        generate_callback: Callable,
+        evaluate_callback: Callable,
+        states_callback: Callable,
+        dump_file_callback: Callable = None,
+        verbose: int = 2,
 ) -> None:
     """
     Run the provided routine object using Xopt.
@@ -617,41 +84,40 @@ def run_routine_xopt(
         Callback function called after evaluating points that takes the form
         `f(data: DataFrame)`.
 
-    pf_callback : Callable
-        Callback function called after Pareto Front object is instantiated
-
     states_callback : Callable
         Callback function called after system states is fetched
     """
 
     environment = routine.environment
-    generator = routine.generator
     initial_points = routine.initial_points
 
-    # Setup Pareto front: soon to die
-    directions = [
-        parse_rule(rule)["direction"] for rule
-        in routine.vocs.objectives.values()
-    ]
-    pf = ParetoFront(directions)
-    if pf_callback:
-        pf_callback(pf)
+    # Log the optimization progress in terminal
+    opt_logger = _get_default_logger(verbose)
 
     # Save system states if applicable
     states = environment.get_system_states()
     if states_callback and (states is not None):
         states_callback(states)
 
+    # Optimization starts
+    print('')
+    solution_meta = (None, None, None, None, None,
+                     routine.vocs.variable_names,
+                     routine.vocs.objective_names,
+                     routine.vocs.constraint_names,
+                     routine.vocs.observable_names)
+    opt_logger.update(Events.OPTIMIZATION_START, solution_meta)
+
     # evaluate initial points:
     # Nikita: more care about the setting var logic,
     # wait or consider timeout/retry
-    result = evaluate_points(initial_points, routine, evaluate_callback)
-
-    # add measurements to generator
-    generator.add_data(result)
-
-    eval_index = 0  # record current evaluation index
-    eval_index += add_to_pf(eval_index, initial_points, result, pf)
+    # TODO: need to evaluate a single point at the time
+    for _, ele in initial_points.iterrows():
+        result = routine.evaluate_data(ele.to_dict())
+        solution = convert_to_solution(result, routine)
+        opt_logger.update(Events.OPTIMIZATION_STEP, solution)
+        if evaluate_callback:
+            evaluate_callback(result)
 
     # Prepare for dumping file
     if dump_file_callback:
@@ -662,36 +128,39 @@ def run_routine_xopt(
             dump_file = f"xopt_states_{ts_start}.yaml"
 
     # perform optimization
-    while True:
-        status = active_callback()
-        if status == 2:
-            raise BadgerRunTerminatedError
-        elif status == 1:
-            time.sleep(0)
-            continue
-
-        # generate points to observe
-        candidates = generator.generate(1)[0]
-        candidates = DataFrame(candidates, index=[0])
-        # generate_callback(generator, candidates)
-        generate_callback(candidates)
-
-        check_run_status(active_callback)
-        # if still active evaluate the points and add to generator
-        # check active_callback evaluate point
-        result = evaluate_points(candidates, routine, evaluate_callback)
-
-        eval_index += add_to_pf(eval_index, candidates, result, pf)
-
-        # Dump Xopt state after each step
-        if dump_file_callback:
-            if combined_results is not None:
-                combined_results = concat(
-                    [combined_results, result], axis=0).reset_index(drop=True)
-            else:
-                combined_results = result
+    try:
+        while True:
+            status = active_callback()
+            if status == 2:
+                raise BadgerRunTerminatedError
+            elif status == 1:
+                time.sleep(0)
+                continue
 
-            dump_state(dump_file, generator, combined_results)
+            # generate points to observe
+            candidates = routine.generator.generate(1)[0]
+            candidates = DataFrame(candidates, index=[0])
+            # generate_callback(generator, candidates)
+            generate_callback(candidates)
+
+            check_run_status(active_callback)
+            # if still active evaluate the points and add to generator
+            # check active_callback evaluate point
+            result = routine.evaluate_data(candidates)
+            solution = convert_to_solution(result, routine)
+            opt_logger.update(Events.OPTIMIZATION_STEP, solution)
+            if evaluate_callback:
+                evaluate_callback(result)
+
+            # Dump Xopt state after each step
+            if dump_file_callback:
+                if combined_results is not None:
+                    combined_results = concat([combined_results, result],
+                                              axis=0).reset_index(drop=True)
+                else:
+                    combined_results = result
 
-        # Add data to generator
-        generator.add_data(result)
+                dump_state(dump_file, routine.generator, combined_results)
+    except Exception as e:
+        opt_logger.update(Events.OPTIMIZATION_END, solution_meta)
+        raise e
diff --git a/src/badger/db.py b/src/badger/db.py
index f9b99c32..b0613ea2 100644
--- a/src/badger/db.py
+++ b/src/badger/db.py
@@ -4,8 +4,9 @@
 logger = logging.getLogger(__name__)
 import yaml
 import sqlite3
+from .routine import Routine
 from .settings import read_value
-from .utils import ystring
+from .utils import get_yaml_string
 from .errors import BadgerConfigError, BadgerDBError
 
 
@@ -68,24 +69,57 @@ def filter_routines(records, tags):
     return records_filtered
 
 
+def extract_descriptions(records):
+    descr_list = []
+    for record in records:
+        try:
+            descr = yaml.safe_load(record[1])['description']
+            descr_list.append(descr)
+        except Exception:
+            descr_list.append('')
+
+    return descr_list
+
+
 @maybe_create_routines_db
-def save_routine(routine):
+def save_routine(routine: Routine):
     db_routine = os.path.join(BADGER_DB_ROOT, 'routines.db')
 
     con = sqlite3.connect(db_routine)
     cur = con.cursor()
 
-    cur.execute('select * from routine where name=:name', {'name': routine['name']})
+    cur.execute('select * from routine where name=:name',
+                {'name': routine.name})
     record = cur.fetchone()
 
-    runs = get_runs_by_routine(routine['name'])
+    runs = get_runs_by_routine(routine.name)
 
     if record and len(runs) == 0:  # update the record
         cur.execute('update routine set config = ?, savedAt = ? where name = ?',
-                    (yaml.dump(routine, sort_keys=False), datetime.now(), routine['name']))
+                    (routine.yaml(), datetime.now(), routine.name))
     else:  # insert a record
         cur.execute('insert into routine values (?, ?, ?)',
-                    (routine['name'], yaml.dump(routine, sort_keys=False), datetime.now()))
+                    (routine.name, routine.yaml(), datetime.now()))
+
+    con.commit()
+    con.close()
+
+
+# This function is not safe and might break database! Use with caution!
+@maybe_create_routines_db
+def update_routine(routine: Routine):
+    db_routine = os.path.join(BADGER_DB_ROOT, 'routines.db')
+
+    con = sqlite3.connect(db_routine)
+    cur = con.cursor()
+
+    cur.execute('select * from routine where name=:name',
+                {'name': routine.name})
+    record = cur.fetchone()
+
+    if record:  # update the record
+        cur.execute('update routine set config = ?, savedAt = ? where name = ?',
+                    (routine.yaml(), datetime.now(), routine.name))
 
     con.commit()
     con.close()
@@ -118,7 +152,7 @@ def remove_routine(name, remove_runs=False):
 
 
 @maybe_create_routines_db
-def load_routine(name):
+def load_routine(name: str):
     db_routine = os.path.join(BADGER_DB_ROOT, 'routines.db')
     con = sqlite3.connect(db_routine)
     cur = con.cursor()
@@ -129,7 +163,10 @@ def load_routine(name):
     con.close()
 
     if len(records) == 1:
-        return yaml.safe_load(records[0][1]), records[0][2]
+        # return yaml.safe_load(records[0][1]), records[0][2]
+        routine_dict = yaml.safe_load(records[0][1])
+        # routine_dict['evaluator'] = None
+        return Routine(**routine_dict), records[0][2]
     elif len(records) == 0:
         # logger.warning(f'Routine {name} not found in the database!')
         return None, None
@@ -144,23 +181,16 @@ def list_routine(keyword='', tags={}):
     con = sqlite3.connect(db_routine)
     cur = con.cursor()
 
-    if not tags:
-        cur.execute(f'select name, savedAt from routine where name like "%{keyword}%" order by savedAt desc')
-
-        records = cur.fetchall()
-        names = [record[0] for record in records]
-        timestamps = [record[1] for record in records]
-        con.close()
-    else:
-        cur.execute(f'select name, config, savedAt from routine where name like "%{keyword}%" order by savedAt desc')
-
-        records = cur.fetchall()
+    cur.execute(f'select name, config, savedAt from routine where name like "%{keyword}%" order by savedAt desc')
+    records = cur.fetchall()
+    if tags:
         records = filter_routines(records, tags)
-        names = [record[0] for record in records]
-        timestamps = [record[2] for record in records]
-        con.close()
+    names = [record[0] for record in records]
+    timestamps = [record[2] for record in records]
+    descriptions = extract_descriptions(records)
+    con.close()
 
-    return names, timestamps
+    return names, timestamps, descriptions
 
 
 @maybe_create_runs_db
@@ -171,9 +201,9 @@ def save_run(run):
     cur = con.cursor()
 
     # Insert or update a record
-    routine_name = run['routine']['name']
+    routine_name = run['routine'].name
     run_filename = run['filename']
-    timestamps = run['data']['timestamp_raw']
+    timestamps = run['data']['timestamp']
     time_start = datetime.fromtimestamp(timestamps[0])
     time_finish = datetime.fromtimestamp(timestamps[-1])
 
@@ -282,7 +312,7 @@ def import_routines(filename):
     con.close()
 
     if failed_list:
-        raise BadgerDBError(ystring(failed_list))
+        raise BadgerDBError(get_yaml_string(failed_list))
 
 
 def export_routines(filename, routine_name_list):
diff --git a/src/badger/environment.py b/src/badger/environment.py
index 01560f12..7d873e94 100644
--- a/src/badger/environment.py
+++ b/src/badger/environment.py
@@ -1,15 +1,16 @@
 from abc import ABC
+from logging import warning
 from typing import ClassVar, Dict, final, List, Optional
 
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny
 
-from .errors import (
+from badger.errors import (
     BadgerEnvObsError,
     BadgerEnvVarError,
     BadgerInterfaceChannelError,
     BadgerNoInterfaceError,
 )
-from .interface import Interface
+from badger.interface import Interface
 
 
 def validate_variable_names(func):
@@ -69,12 +70,12 @@ class Environment(BaseModel, ABC):
     )
 
     # Class variables
-    name: ClassVar[str]
+    name: ClassVar[str] = Field(description="environment name")
     variables: ClassVar[Dict[str, List]]  # bounds list could be empty for var
     observables: ClassVar[List[str]]
 
     # Interface
-    interface: Optional[Interface] = None
+    interface: Optional[SerializeAsAny[Interface]] = None
     # Put all other env params here
     # params: float = Field(..., description='Example env parameter')
 
@@ -113,6 +114,17 @@ def variables_changed(self, variables_input: Dict[str, float]):
     def get_system_states(self) -> Optional[Dict]:
         return None
 
+    ############################################################
+    # Expert level of customization
+    ############################################################
+
+    # @model_serializer
+    # def ser_model(self, **kwargs) -> Dict[str, Any]:
+    #     default_dict = super().model_dump(**kwargs)
+    #     default_dict["name"] = self.name
+
+    #     return default_dict
+
     ############################################################
     # Should never be overridden
     ############################################################
@@ -221,3 +233,32 @@ def _get_bounds(
             self.variables.update(self.get_bounds(variable_names_new))
 
         return {k: self.variables[k] for k in variable_names}
+
+
+def instantiate_env(env_class, configs, manager=None):
+    # Configure interface
+    # TODO: figure out the correct logic
+    # It seems that the interface should be given rather than
+    # initialized here
+    from badger.factory import get_intf
+
+    try:
+        intf_name = configs["interface"][0]
+    except KeyError:
+        intf_name = None
+    except Exception as e:
+        warning(e)
+        intf_name = None
+
+    if intf_name is not None:
+        if manager is None:
+            Interface, _ = get_intf(intf_name)
+            intf = Interface()
+        else:
+            intf = manager.Interface()
+    else:
+        intf = None
+
+    env = env_class(interface=intf, **configs["params"])
+
+    return env
diff --git a/src/badger/extension.py b/src/badger/extension.py
index 1078c103..a407fc6a 100644
--- a/src/badger/extension.py
+++ b/src/badger/extension.py
@@ -13,14 +13,14 @@ def name(self) -> str:
     def __init__(self):
         pass
 
-    # List all available algorithms
+    # List all available generators
     @abstractmethod
-    def list_algo(self) -> List[str]:
+    def list_generator(self) -> List[str]:
         pass
 
-    # Get config of an algorithm
+    # Get config of an generator
     @abstractmethod
-    def get_algo_config(self, name: str):
+    def get_generator_config(self, name: str):
         pass
 
     # Run an optimization with an array-style evaluate function
diff --git a/src/badger/factory.py b/src/badger/factory.py
index e2c9fc6b..7af01e15 100644
--- a/src/badger/factory.py
+++ b/src/badger/factory.py
@@ -10,14 +10,19 @@
 import os
 import importlib
 import yaml
-import json
-from xopt.generators import generators
+from xopt.generators import generators, get_generator
+
 import logging
 logger = logging.getLogger(__name__)
 
-
 LOAD_LOCAL_ALGO = False
-
+ALGO_EXCLUDED = [
+    'bayesian_exploration',
+    'cnsga',
+    'mggpo',
+    'time_dependent_upper_confidence_bound',
+    'multi_fidelity'
+]
 
 # Check badger plugin root
 BADGER_PLUGIN_ROOT = read_value('BADGER_PLUGIN_ROOT')
@@ -37,12 +42,12 @@
 def scan_plugins(root):
     factory = {}
 
-    # Do not scan local algorithms if option disabled
+    # Do not scan local generators if option disabled
     if LOAD_LOCAL_ALGO:
-        ptype_list = ['algorithm', 'interface', 'environment']
+        ptype_list = ['generator', 'interface', 'environment']
     else:
         ptype_list = ['interface', 'environment']
-        factory['algorithm'] = {}
+        factory['generator'] = {}
 
     for ptype in ptype_list:
         factory[ptype] = {}
@@ -64,7 +69,7 @@ def scan_plugins(root):
 
 
 def load_plugin(root, pname, ptype):
-    assert ptype in ['algorithm', 'interface',
+    assert ptype in ['generator', 'interface',
                      'environment'], f'Invalid plugin type {ptype}'
 
     proot = os.path.join(root, f'{ptype}s')
@@ -87,7 +92,7 @@ def load_plugin(root, pname, ptype):
         _e.configs = configs  # attach information to the exception
         raise _e
 
-    if ptype == 'algorithm':
+    if ptype == 'generator':
         plugin = [module.optimize, configs]
     elif ptype == 'interface':
         params = module.Interface.model_json_schema()['properties']
@@ -134,7 +139,7 @@ def load_plugin(root, pname, ptype):
 
 
 def load_docs(root, pname, ptype):
-    assert ptype in ['algorithm', 'interface',
+    assert ptype in ['generator', 'interface',
                      'environment'], f'Invalid plugin type {ptype}'
 
     proot = os.path.join(root, f'{ptype}s')
@@ -146,7 +151,8 @@ def load_docs(root, pname, ptype):
             readme = f.read()
         return readme
     except:
-        raise BadgerInvalidDocsError(f'Error loading docs for {ptype} {pname}: docs not found')
+        raise BadgerInvalidDocsError(
+            f'Error loading docs for {ptype} {pname}: docs not found')
 
 
 def get_plug(root, name, ptype):
@@ -170,63 +176,7 @@ def scan_extensions(root):
     return extensions
 
 
-def get_algo_params(cls):
-    params = {}
-    for k in cls.__fields__:
-        if k in ['vocs', 'data']:
-            continue
-
-        v = cls.__fields__[k]
-        try:
-            _ = v.default
-        except AttributeError:
-            params[k] = get_algo_params(v)
-            continue
-
-        try:
-            params[k] = json.loads(v.default.json())
-        except AttributeError:
-            params[k] = v.default
-
-    return params
-
-
-def get_algo(name):
-    from xopt import __version__
-
-    try:
-        from xopt.generators import generator_default_options
-
-        params = generator_default_options[name].dict()
-    except ImportError:  # Xopt v2.0+
-        from xopt.generators import get_generator
-
-        params = get_algo_params(get_generator(name))
-
-    try:
-        _ = params["start_from_current"]
-    except KeyError:
-        params["start_from_current"] = True
-    try:  # remove custom GP kernel to avoid yaml parsing error for now
-        del params["model"]["function"]
-    except KeyError:
-        pass
-    except TypeError:
-        pass
-
-    try:
-        return [None, {
-            "name": name,
-            "version": __version__,
-            "dependencies": ["xopt"],
-            "params": params,
-        }]
-    except Exception as e:
-        raise e
-        # raise Exception(f'Algorithm {name} is not supported')
-
-
-def get_algo_docs(name):
+def get_generator_docs(name):
     return generators[name].__doc__
 
 
@@ -238,10 +188,20 @@ def get_env(name):
     return get_plug(BADGER_PLUGIN_ROOT, name, 'environment')
 
 
-def list_algo():
-    algos = list(generators.keys())
+def list_generators():
+    try:
+        from xopt.generators import try_load_all_generators
+
+        try_load_all_generators()
+    except ImportError:  # this API changed somehow
+        pass  # there is nothing we can do...
+    generator_names = list(generators.keys())
+    # Filter the names
+    generator_names = [n for n in generator_names if n not in ALGO_EXCLUDED]
+    return sorted(generator_names)
+
 
-    return sorted(algos)
+get_generator = get_generator
 
 
 def list_intf():
diff --git a/src/badger/gui/default/__init__.py b/src/badger/gui/default/__init__.py
index e081c3b6..67f3c5ec 100644
--- a/src/badger/gui/default/__init__.py
+++ b/src/badger/gui/default/__init__.py
@@ -84,7 +84,7 @@ def launch_gui():
     window.show()
 
     # Show the saving SCORE heads-up
-    QMessageBox.information(
-            window, 'Heads-up!', 'This might be a good time to save a SCORE.')
+    # QMessageBox.information(
+    #        window, 'Heads-up!', 'This might be a good time to save a SCORE.')
 
     sys.exit(app.exec())
diff --git a/src/badger/gui/default/components/analysis_extensions.py b/src/badger/gui/default/components/analysis_extensions.py
new file mode 100644
index 00000000..d581fb1e
--- /dev/null
+++ b/src/badger/gui/default/components/analysis_extensions.py
@@ -0,0 +1,56 @@
+from abc import abstractmethod, ABC
+
+import pyqtgraph as pg
+from PyQt5.QtCore import pyqtSignal
+from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QWidget
+
+from badger.core import Routine
+
+
+class AnalysisExtension(QDialog):
+    window_closed = pyqtSignal(object)
+
+    def __init__(self, parent=None):
+        super().__init__(parent=parent)
+
+    @abstractmethod
+    def update_window(self, routine: Routine):
+        pass
+
+    def closeEvent(self, event) -> None:
+        self.window_closed.emit(self)
+        super().closeEvent(event)
+
+
+class ParetoFrontViewer(AnalysisExtension):
+    def __init__(self, parent=None):
+        super().__init__(parent=parent)
+
+        self.setWindowTitle("Pareto Front Viewer")
+
+        self.plot_widget = pg.PlotWidget()
+
+        self.scatter_plot = self.plot_widget.plot(pen=None, symbol='o', symbolSize=10)
+
+        layout = QVBoxLayout()
+        layout.addWidget(self.plot_widget)
+        self.setLayout(layout)
+
+    def update_window(self, routine: Routine):
+        if len(routine.vocs.objective_names) != 2:
+            raise ValueError("cannot use pareto front viewer unless there are 2 "
+                             "objectives")
+
+        x_name = routine.vocs.objective_names[0]
+        y_name = routine.vocs.objective_names[1]
+
+        if routine.data is not None:
+            x = routine.data[x_name]
+            y = routine.data[y_name]
+
+            # Update the scatter plot
+            self.scatter_plot.setData(x=x, y=y)
+
+        # set labels
+        self.plot_widget.setLabel("left", y_name)
+        self.plot_widget.setLabel("bottom", x_name)
diff --git a/src/badger/gui/default/components/data_table.py b/src/badger/gui/default/components/data_table.py
index a2e75d7c..ec92083b 100644
--- a/src/badger/gui/default/components/data_table.py
+++ b/src/badger/gui/default/components/data_table.py
@@ -1,7 +1,21 @@
+from pandas import DataFrame
 from PyQt5.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QHeaderView
 from PyQt5.QtCore import Qt
 
 
+stylesheet = '''
+    QTableWidget
+    {
+        alternate-background-color: #262E38;
+    }
+    QTableWidget::item::selected
+    {
+        background-color: #B3E5FC;
+        color: #000000;
+    }
+'''
+
+
 # https://stackoverflow.com/questions/60715462/how-to-copy-and-paste-multiple-cells-in-qtablewidget-in-pyqt5
 class TableWithCopy(QTableWidget):
     """
@@ -38,19 +52,17 @@ def update_table(table, data=None):
     if data is None:
         return table
 
-    _data = data.copy()
-    m = len(_data['timestamp_raw'])
-    del _data['timestamp_raw']
-    del _data['timestamp']
-    n = len(_data)
+    _data = data.drop(columns=['timestamp', 'xopt_error', 'xopt_runtime'])
 
+    m, n = _data.shape
     table.setRowCount(m)
     table.setColumnCount(n)
-    for i, key in enumerate(_data.keys()):
-        for j, v in enumerate(_data[key]):
-            table.setItem(j, i, QTableWidgetItem(f'{v:g}'))
-    table.setHorizontalHeaderLabels(list(_data.keys()))
-    table.setVerticalHeaderLabels([str(i) for i in range(m)])  # row index starts from 0
+    for i in range(m):
+        for j in range(n):
+            v = _data.iloc[i, j]
+            table.setItem(i, j, QTableWidgetItem(f'{v:g}'))
+    table.setHorizontalHeaderLabels(list(_data.columns))
+    table.setVerticalHeaderLabels(list(map(str, _data.index)))  # row index starts from 0
     table.horizontalHeader().setVisible(True)
 
     return table
@@ -81,7 +93,7 @@ def add_row(table, row):
 def data_table(data=None):
     table = TableWithCopy()
     table.setAlternatingRowColors(True)
-    table.setStyleSheet('alternate-background-color: #262E38;')
+    table.setStyleSheet(stylesheet)
     table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
     return update_table(table, data)
 
@@ -89,7 +101,7 @@ def data_table(data=None):
 def init_data_table(variable_names=None):
     table = TableWithCopy()
     table.setAlternatingRowColors(True)
-    table.setStyleSheet('alternate-background-color: #262E38;')
+    table.setStyleSheet(stylesheet)
     table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
 
     table.setRowCount(10)
@@ -147,8 +159,12 @@ def update_init_data_table(table, variable_names):
                 table.setItem(row, col, QTableWidgetItem(''))
 
 
-def set_init_data_table(table, data_dict):
+def set_init_data_table(table, data: DataFrame):
     variable_names = get_horizontal_header_as_list(table)
+    try:
+        data_dict = data.to_dict('list')
+    except AttributeError:
+        data_dict = None
 
     # Clear the table
     for col, name in enumerate(variable_names):
diff --git a/src/badger/gui/default/components/eliding_label.py b/src/badger/gui/default/components/eliding_label.py
new file mode 100644
index 00000000..95427b67
--- /dev/null
+++ b/src/badger/gui/default/components/eliding_label.py
@@ -0,0 +1,98 @@
+# https://stackoverflow.com/a/67628976/4263605
+from PyQt5 import QtCore, QtWidgets, QtGui
+
+
+class ElidingLabel(QtWidgets.QLabel):
+    """Label with text elision.
+
+    QLabel which will elide text too long to fit the widget.  Based on:
+    https://doc-snapshots.qt.io/qtforpython-5.15/overviews/qtwidgets-widgets-elidedlabel-example.html
+
+    Parameters
+    ----------
+    text : str
+
+        Label text.
+
+    mode : QtCore.Qt.TextElideMode
+
+       Specify where ellipsis should appear when displaying texts that
+       don't fit.
+
+       Default is QtCore.Qt.ElideMiddle.
+
+       Possible modes:
+         QtCore.Qt.ElideLeft
+         QtCore.Qt.ElideMiddle
+         QtCore.Qt.ElideRight
+
+    parent : QWidget
+
+       Parent widget.  Default is None.
+
+    f : Qt.WindowFlags()
+
+       https://doc-snapshots.qt.io/qtforpython-5.15/PySide2/QtCore/Qt.html#PySide2.QtCore.PySide2.QtCore.Qt.WindowType
+
+    """
+
+    elision_changed = QtCore.pyqtSignal(bool)
+
+    def __init__(self, text='', mode=QtCore.Qt.ElideMiddle, **kwargs):
+        super().__init__(**kwargs)
+
+        self._mode = mode
+        self.is_elided = False
+        self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
+        self.setText(text)
+
+    def setText(self, text):
+        self._contents = text
+
+        # This line set for testing.  Its value is the return value of
+        # QFontMetrics.elidedText, set in paintEvent.  The variable
+        # must be initialized for testing.  The value should always be
+        # the same as contents when not elided.
+        self._elided_line = text
+
+        self.update()
+
+    def text(self):
+        return self._contents
+
+    def paintEvent(self, event):
+        super().paintEvent(event)
+
+        did_elide = False
+
+        painter = QtGui.QPainter(self)
+        font_metrics = painter.fontMetrics()
+        text_width = font_metrics.horizontalAdvance(self.text())
+
+        # layout phase
+        text_layout = QtGui.QTextLayout(self._contents, painter.font())
+        text_layout.beginLayout()
+
+        while True:
+            line = text_layout.createLine()
+
+            if not line.isValid():
+                break
+
+            line.setLineWidth(self.width())
+
+            if text_width >= self.width():
+                self._elided_line = font_metrics.elidedText(
+                    self._contents, self._mode, self.width())
+                painter.drawText(
+                    QtCore.QPoint(0, font_metrics.ascent()), self._elided_line)
+                did_elide = line.isValid()
+                break
+            else:
+                line.draw(painter, QtCore.QPoint(0, 0))
+
+        text_layout.endLayout()
+
+        if did_elide != self.is_elided:
+            self.is_elided = did_elide
+            self.elision_changed.emit(did_elide)
diff --git a/src/badger/gui/default/components/env_cbox.py b/src/badger/gui/default/components/env_cbox.py
index 37699c0d..ce13541e 100644
--- a/src/badger/gui/default/components/env_cbox.py
+++ b/src/badger/gui/default/components/env_cbox.py
@@ -8,6 +8,7 @@
 from ....settings import read_value
 from ....utils import strtobool
 
+LABEL_WIDTH = 80
 
 class BadgerEnvBox(CollapsibleBox):
     def __init__(self, parent=None, envs=[]):
@@ -25,7 +26,7 @@ def init_ui(self):
         hbox_name = QHBoxLayout(name)
         hbox_name.setContentsMargins(0, 0, 0, 0)
         lbl = QLabel('Name')
-        lbl.setFixedWidth(64)
+        lbl.setFixedWidth(LABEL_WIDTH)
         self.cb = cb = QComboBox()
         cb.setItemDelegate(QStyledItemDelegate())
         cb.addItems(self.envs)
@@ -46,7 +47,7 @@ def init_ui(self):
         vbox_lbl_params = QVBoxLayout(lbl_params_col)
         vbox_lbl_params.setContentsMargins(0, 0, 0, 0)
         lbl_params = QLabel('Params')
-        lbl_params.setFixedWidth(64)
+        lbl_params.setFixedWidth(LABEL_WIDTH)
         vbox_lbl_params.addWidget(lbl_params)
         vbox_lbl_params.addStretch(1)
         hbox_params.addWidget(lbl_params_col)
@@ -76,8 +77,8 @@ def init_ui(self):
         lbl_var_col = QWidget()
         vbox_lbl_var = QVBoxLayout(lbl_var_col)
         vbox_lbl_var.setContentsMargins(0, 0, 0, 0)
-        lbl_var = QLabel('Vars')
-        lbl_var.setFixedWidth(64)
+        lbl_var = QLabel('Variables')
+        lbl_var.setFixedWidth(LABEL_WIDTH)
         vbox_lbl_var.addWidget(lbl_var)
         vbox_lbl_var.addStretch(1)
         hbox_var.addWidget(lbl_var_col)
@@ -112,7 +113,7 @@ def init_ui(self):
         self.var_table = VariableTable()
         vbox_var_edit.addWidget(self.var_table)
 
-        cbox_init = CollapsibleBox(self, ' Initial Points', tooltip='If set, it takes precedence over the start from current setting in algorithm configuration.')
+        cbox_init = CollapsibleBox(self, ' Initial Points', tooltip='If set, it takes precedence over the start from current setting in generator configuration.')
         vbox_var_edit.addWidget(cbox_init)
         vbox_init = QVBoxLayout()
         vbox_init.setContentsMargins(8, 8, 8, 8)
@@ -145,8 +146,8 @@ def init_ui(self):
         lbl_obj_col = QWidget()
         vbox_lbl_obj = QVBoxLayout(lbl_obj_col)
         vbox_lbl_obj.setContentsMargins(0, 0, 0, 0)
-        lbl_obj = QLabel('Objs')
-        lbl_obj.setFixedWidth(64)
+        lbl_obj = QLabel('Objectives')
+        lbl_obj.setFixedWidth(LABEL_WIDTH)
         vbox_lbl_obj.addWidget(lbl_obj)
         vbox_lbl_obj.addStretch(1)
         hbox_obj.addWidget(lbl_obj_col)
@@ -184,8 +185,8 @@ def init_ui(self):
         lbl_con_col = QWidget()
         vbox_lbl_con = QVBoxLayout(lbl_con_col)
         vbox_lbl_con.setContentsMargins(0, 0, 0, 0)
-        lbl_con = QLabel('Cons')
-        lbl_con.setFixedWidth(64)
+        lbl_con = QLabel('Constraints')
+        lbl_con.setFixedWidth(LABEL_WIDTH)
         vbox_lbl_con.addWidget(lbl_con)
         vbox_lbl_con.addStretch(1)
         hbox_con.addWidget(lbl_con_col)
@@ -218,8 +219,8 @@ def init_ui(self):
         lbl_sta_col = QWidget()
         vbox_lbl_sta = QVBoxLayout(lbl_sta_col)
         vbox_lbl_sta.setContentsMargins(0, 0, 0, 0)
-        lbl_sta = QLabel('States')
-        lbl_sta.setFixedWidth(64)
+        lbl_sta = QLabel('Constants')
+        lbl_sta.setFixedWidth(LABEL_WIDTH)
         vbox_lbl_sta.addWidget(lbl_sta)
         vbox_lbl_sta.addStretch(1)
         hbox_sta.addWidget(lbl_sta_col)
diff --git a/src/badger/gui/default/components/extensions_palette.py b/src/badger/gui/default/components/extensions_palette.py
new file mode 100644
index 00000000..985b9a7a
--- /dev/null
+++ b/src/badger/gui/default/components/extensions_palette.py
@@ -0,0 +1,129 @@
+import traceback
+
+from PyQt5.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
+    QWidget, QLabel, QMessageBox, QSizePolicy
+from PyQt5.QtCore import Qt
+from badger.gui.default.components.analysis_extensions import AnalysisExtension, \
+    ParetoFrontViewer
+
+
+class ExtensionsPalette(QMainWindow):
+    """
+    A QMainWindow-based user interface for managing and launching extensions in Badger.
+
+    Parameters
+    ----------
+    run_monitor : RunMonitor
+        The run monitor associated with the palette.
+
+    Attributes
+    ----------
+    base_text : str
+        Base text for the number of active extensions.
+    text_box : QLabel
+        QLabel widget for displaying the number of active extensions.
+    btn_data_viewer : QPushButton
+        QPushButton for launching the ParetoFrontViewer extension.
+
+    Methods
+    -------
+    n_active_extensions
+        Property to get the number of active extensions.
+    update_palette
+        Update the display of the active extensions count.
+    add_pf_viewer
+        Open the ParetoFrontViewer extension.
+    add_child_window_to_monitor(child_window)
+        Add a child window to the run monitor.
+
+    """
+    def __init__(self, run_monitor):
+        """
+        Initialize the ExtensionsPalette.
+
+        Parameters
+        ----------
+        run_monitor : RunMonitor
+            The run monitor associated with the palette.
+
+        """
+        super().__init__(parent=run_monitor)
+
+        self.run_monitor = run_monitor
+
+        self.setWindowTitle('Badger Extensions Palette')
+        self.resize(320, 240)
+
+        central_widget = QWidget()
+        self.setCentralWidget(central_widget)
+
+        layout = QVBoxLayout()
+
+        self.base_text = "Number of active exensions: "
+        self.text_box = QLabel(self.base_text + "0", self)
+        self.text_box.setSizePolicy(
+            QSizePolicy.Expanding,
+            QSizePolicy.Preferred
+        )
+        self.text_box.setAlignment(Qt.AlignCenter)
+
+        self.btn_data_viewer = QPushButton('ParetoFrontViewer')
+
+        layout.addWidget(self.btn_data_viewer)
+        layout.addStretch()
+        layout.addWidget(self.text_box)
+
+        central_widget.setLayout(layout)
+
+        self.btn_data_viewer.clicked.connect(self.add_pf_viewer)
+
+    @property
+    def n_active_extensions(self):
+        """
+        Property to get the number of active extensions.
+
+        Returns
+        -------
+        int
+            The number of active extensions.
+
+        """
+        return len(self.run_monitor.active_extensions)
+
+    def update_palette(self):
+        self.text_box.setText(
+            self.base_text + str(self.n_active_extensions))
+
+    def add_pf_viewer(self):
+        """
+        Open the ParetoFrontViewer extension.
+
+        """
+        self.add_child_window_to_monitor(ParetoFrontViewer(self))
+
+    def add_child_window_to_monitor(self, child_window: AnalysisExtension):
+        """
+        Add a child window to the run monitor.
+
+        Parameters
+        ----------
+        child_window : AnalysisExtension
+            The child window (extension) to add to the run monitor.
+
+        """
+        child_window.window_closed.connect(self.run_monitor.extension_window_closed)
+
+        if self.run_monitor.routine is not None:
+            try:
+                child_window.update_window(
+                    self.run_monitor.routine
+                )
+                self.run_monitor.active_extensions.append(child_window)
+                child_window.show()
+            except ValueError:
+                QMessageBox.warning(
+                    self, 'Extension is not applicable!',
+                    traceback.format_exc()
+                )
+
+        self.update_palette()
diff --git a/src/badger/gui/default/components/algo_cbox.py b/src/badger/gui/default/components/generator_cbox.py
similarity index 96%
rename from src/badger/gui/default/components/algo_cbox.py
rename to src/badger/gui/default/components/generator_cbox.py
index 033b19ea..f373254c 100644
--- a/src/badger/gui/default/components/algo_cbox.py
+++ b/src/badger/gui/default/components/generator_cbox.py
@@ -7,10 +7,10 @@
 
 class BadgerAlgoBox(CollapsibleBox):
 
-    def __init__(self, parent=None, algos=[], scaling_functions=[]):
+    def __init__(self, parent=None, generators=[], scaling_functions=[]):
         super().__init__(parent, ' Algorithm')
 
-        self.algos = algos
+        self.generators = generators
         self.scaling_functions = scaling_functions
 
         self.init_ui()
@@ -26,7 +26,7 @@ def init_ui(self):
         lbl.setFixedWidth(64)
         self.cb = cb = QComboBox()
         cb.setItemDelegate(QStyledItemDelegate())
-        cb.addItems(self.algos)
+        cb.addItems(self.generators)
         cb.setCurrentIndex(-1)
         hbox_name.addWidget(lbl)
         hbox_name.addWidget(cb, 1)
diff --git a/src/badger/gui/default/components/obj_table.py b/src/badger/gui/default/components/obj_table.py
index 2f5be380..3996b0ec 100644
--- a/src/badger/gui/default/components/obj_table.py
+++ b/src/badger/gui/default/components/obj_table.py
@@ -73,9 +73,8 @@ def update_rules(self):
             self.rules[name] = rule.currentText()
 
     def set_rules(self, objectives):
-        for obj in objectives:
-            name = next(iter(obj))
-            self.rules[name] = obj[name]
+        for name in objectives:
+            self.rules[name] = objectives[name]
 
         self.update_objectives(self.objectives, 2)
 
@@ -179,12 +178,10 @@ def update_objectives(self, objectives, filtered=0):
         header.setVisible(True)
 
     def export_objectives(self):
-        objectives_exported = []
+        objectives_exported = {}
         for obj in self.all_objectives:
             name = next(iter(obj))
-            _obj = {}
-            _obj[name] = self.rules[name]
             if self.is_checked(name):
-                objectives_exported.append(_obj)
+                objectives_exported[name] = self.rules[name]
 
         return objectives_exported
diff --git a/src/badger/gui/default/components/routine_editor.py b/src/badger/gui/default/components/routine_editor.py
index 53d7b438..20c41ddf 100644
--- a/src/badger/gui/default/components/routine_editor.py
+++ b/src/badger/gui/default/components/routine_editor.py
@@ -3,7 +3,8 @@
 from PyQt5.QtCore import pyqtSignal
 from PyQt5.QtGui import QFont
 from .routine_page import BadgerRoutinePage
-from ....utils import ystring
+
+from ....routine import Routine
 
 
 class BadgerRoutineEditor(QWidget):
@@ -72,8 +73,11 @@ def config_logic(self):
         self.btn_cancel.clicked.connect(self.cancel_create_routine)
         self.btn_save.clicked.connect(self.save_routine)
 
-    def set_routine(self, routine):
-        self.routine_edit.setText(ystring(routine))
+    def set_routine(self, routine: Routine):
+        try:
+            self.routine_edit.setText(routine.yaml())
+        except AttributeError:
+            self.routine_edit.setText("")
         self.routine_page.refresh_ui(routine)
 
     def edit_routine(self):
diff --git a/src/badger/gui/default/components/routine_item.py b/src/badger/gui/default/components/routine_item.py
index 92f40cd9..d1bb5631 100644
--- a/src/badger/gui/default/components/routine_item.py
+++ b/src/badger/gui/default/components/routine_item.py
@@ -1,7 +1,10 @@
 from datetime import datetime
-from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QSizePolicy
+from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel
+from PyQt5.QtWidgets import QSizePolicy, QMessageBox
 from PyQt5.QtCore import Qt, pyqtSignal
 from PyQt5.QtGui import QFont
+from .eliding_label import ElidingLabel
+from ..utils import create_button
 
 
 stylesheet_normal = '''
@@ -41,16 +44,34 @@
 }
 '''
 
+stylesheet_fav = '''
+QPushButton:hover:pressed
+{
+    background-color: #FFEA00;
+}
+QPushButton:hover
+{
+    background-color: #FFD600;
+}
+QPushButton
+{
+    background-color: none;
+    border-radius: 0px;
+}
+'''
+
+
 class BadgerRoutineItem(QWidget):
     sig_del = pyqtSignal(str)
 
-    def __init__(self, name, timestamp, parent=None):
+    def __init__(self, name, timestamp, description='', parent=None):
         super().__init__(parent)
 
         self.activated = False
         self.hover = False
         self.name = name
         self.timestamp = timestamp
+        self.description = description
 
         self.init_ui()
         self.config_logic()
@@ -71,23 +92,38 @@ def init_ui(self):
         vbox = QVBoxLayout(info_panel)
         vbox.setContentsMargins(8, 8, 8, 8)
         vbox.setSpacing(0)
-        routine_name = QLabel(self.name)
+        name_panel = QWidget()
+        hbox_name = QHBoxLayout(name_panel)
+        hbox_name.setContentsMargins(4, 0, 0, 0)
+        routine_name = ElidingLabel(self.name)
+        routine_name.setMinimumWidth(180)
         routine_name.setFont(cool_font)
-        vbox.addWidget(routine_name)
+        hbox_name.addWidget(routine_name)
+        vbox.addWidget(name_panel)
         _timestamp = datetime.fromisoformat(self.timestamp)
         time_str = _timestamp.strftime('%m/%d/%Y, %H:%M:%S')
         time_created = QLabel(time_str)
         vbox.addWidget(time_created)
 
-        self.btn_del = btn_del = QPushButton('X')
+        # Routine tools
+        self.btn_fav = btn_fav = create_button(
+            "star.png", "Favorite routine", stylesheet_fav, size=None)
+        btn_fav.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
+        btn_fav.setFixedWidth(32)
+        btn_fav.hide()  # hide it for now
+        self.btn_del = btn_del = create_button(
+            "trash.png", "Delete routine", stylesheet_del, size=None)
         btn_del.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
-        btn_del.setFixedWidth(24)
-        btn_del.setStyleSheet(stylesheet_del)
-        btn_del.hide()
+        btn_del.setFixedWidth(32)
+        # btn_del.hide()
+        hbox.addWidget(btn_fav)
         hbox.addWidget(btn_del)
 
+        self.update_tooltip()
+
     def config_logic(self):
         self.btn_del.clicked.connect(self.delete_routine)
+        # self.btn_fav.clicked.connect(self.favorite_routine)
 
     def activate(self):
         self.activated = True
@@ -105,7 +141,8 @@ def deactivate(self):
 
     def enterEvent(self, event):
         self.hover = True
-        self.btn_del.show()
+        # self.btn_fav.show()
+        # self.btn_del.show()
         if self.activated:
             self.setStyleSheet(stylesheet_activate_hover)
         else:
@@ -113,11 +150,28 @@ def enterEvent(self, event):
 
     def leaveEvent(self, event):
         self.hover = False
-        self.btn_del.hide()
+        # self.btn_fav.hide()
+        # self.btn_del.hide()
         if self.activated:
             self.setStyleSheet(stylesheet_activate)
         else:
             self.setStyleSheet(stylesheet_normal)
 
     def delete_routine(self):
+        reply = QMessageBox.question(
+            self.parent(), 'Delete routine',
+            f'Are you sure you want to delete routine {self.name}?',
+            QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
+        if reply != QMessageBox.Yes:
+            return
+
         self.sig_del.emit(self.name)
+
+    def update_tooltip(self):
+        _timestamp = datetime.fromisoformat(self.timestamp)
+        time_str = _timestamp.strftime('%m/%d/%Y, %H:%M:%S')
+        self.setToolTip(f"name: {self.name}\ncreated at: {time_str}\ndescription:\n{self.description}")
+
+    def update_description(self, descr):
+        self.description = descr
+        self.update_tooltip()
diff --git a/src/badger/gui/default/components/routine_page.py b/src/badger/gui/default/components/routine_page.py
index 931a3c2d..d7ace770 100644
--- a/src/badger/gui/default/components/routine_page.py
+++ b/src/badger/gui/default/components/routine_page.py
@@ -1,29 +1,37 @@
-from PyQt5.QtWidgets import QLineEdit, QListWidgetItem, QWidget, QVBoxLayout, QHBoxLayout
-from PyQt5.QtWidgets import QGroupBox, QLineEdit, QLabel, QMessageBox, QSizePolicy
-from PyQt5.QtWidgets import QTableWidgetItem
-from PyQt5.QtCore import Qt
 import sqlite3
+import traceback
+from typing import List
+
 import numpy as np
 import pandas as pd
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtWidgets import QGroupBox, QLineEdit, QLabel, QPushButton
+from PyQt5.QtWidgets import QListWidgetItem, QMessageBox, QWidget
+from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout
+from PyQt5.QtWidgets import QTableWidgetItem, QPlainTextEdit, QSizePolicy
 from coolname import generate_slug
-from ....factory import list_algo, list_env, get_algo, get_env
-from ....utils import ystring, load_config, config_list_to_dict, strtobool
-from ....core import normalize_routine, instantiate_env, list_scaling_func, get_scaling_default_params
-from ....db import save_routine, remove_routine
+from pydantic import ValidationError
+from xopt import VOCS
+from xopt.generators import get_generator_defaults
+
+from .generator_cbox import BadgerAlgoBox
 from .constraint_item import constraint_item
-from .state_item import state_item
-from .algo_cbox import BadgerAlgoBox
+from .data_table import get_table_content_as_dict, set_init_data_table
 from .env_cbox import BadgerEnvBox
 from .filter_cbox import BadgerFilterBox
-from ..windows.review_dialog import BadgerReviewDialog
-from ..windows.var_dialog import BadgerVariableDialog
-from ..windows.edit_script_dialog import BadgerEditScriptDialog
+from .state_item import state_item
 from ..windows.docs_window import BadgerDocsWindow
+from ..windows.edit_script_dialog import BadgerEditScriptDialog
 from ..windows.lim_vrange_dialog import BadgerLimitVariableRangeDialog
-from .data_table import get_table_content_as_dict, set_init_data_table
-from ....settings import read_value
+from ..windows.review_dialog import BadgerReviewDialog
+from ..windows.var_dialog import BadgerVariableDialog
+from ....db import save_routine, remove_routine, update_routine
+from ....environment import instantiate_env
 from ....errors import BadgerRoutineError
-
+from ....factory import list_generators, list_env, get_env
+from ....routine import Routine
+from ....settings import read_value
+from ....utils import get_yaml_string, load_config, strtobool
 
 CONS_RELATION_DICT = {
     '>': 'GREATER_THAN',
@@ -33,15 +41,16 @@
 
 
 class BadgerRoutinePage(QWidget):
+    sig_updated = pyqtSignal(str, str)  # routine name, routine description
+
     def __init__(self):
         super().__init__()
 
-        self.algos = list_algo()
+        self.generators = list_generators()
         self.envs = list_env()
         self.env = None
         self.routine = None
         self.script = ''
-        self.scaling_functions = list_scaling_func()
         self.window_docs = BadgerDocsWindow(self, '')
 
         # Limit variable ranges
@@ -72,6 +81,37 @@ def init_ui(self):
         hbox_name.addWidget(edit_save, 1)
         vbox_meta.addWidget(name, alignment=Qt.AlignTop)
 
+        # Description
+        descr = QWidget()
+        hbox_descr = QHBoxLayout(descr)
+        hbox_descr.setContentsMargins(0, 0, 0, 0)
+        lbl_descr_col = QWidget()
+        vbox_lbl_descr = QVBoxLayout(lbl_descr_col)
+        vbox_lbl_descr.setContentsMargins(0, 0, 0, 0)
+        lbl_descr = QLabel('Descr')
+        lbl_descr.setFixedWidth(64)
+        vbox_lbl_descr.addWidget(lbl_descr)
+        vbox_lbl_descr.addStretch(1)
+        hbox_descr.addWidget(lbl_descr_col)
+
+        edit_descr_col = QWidget()
+        vbox_descr_edit = QVBoxLayout(edit_descr_col)
+        vbox_descr_edit.setContentsMargins(0, 0, 0, 0)
+        self.edit_descr = edit_descr = QPlainTextEdit()
+        edit_descr.setMaximumHeight(80)
+        vbox_descr_edit.addWidget(edit_descr)
+        descr_bar = QWidget()
+        hbox_descr_bar = QHBoxLayout(descr_bar)
+        hbox_descr_bar.setContentsMargins(0, 0, 0, 0)
+        self.btn_descr_update = btn_update = QPushButton("Update Description")
+        btn_update.setDisabled(True)
+        btn_update.setFixedSize(128, 24)
+        hbox_descr_bar.addStretch(1)
+        hbox_descr_bar.addWidget(btn_update)
+        vbox_descr_edit.addWidget(descr_bar)
+        hbox_descr.addWidget(edit_descr_col)
+        vbox_meta.addWidget(descr)
+
         # Tags
         self.cbox_tags = cbox_tags = BadgerFilterBox(title=' Tags')
         if not strtobool(read_value('BADGER_ENABLE_ADVANCED')):
@@ -82,9 +122,9 @@ def init_ui(self):
         vbox.addWidget(group_meta)
 
         # Algo box
-        self.algo_box = BadgerAlgoBox(None, self.algos, self.scaling_functions)
-        self.algo_box.expand()  # expand the box initially
-        vbox.addWidget(self.algo_box)
+        self.generator_box = BadgerAlgoBox(None, self.generators)
+        self.generator_box.expand()  # expand the box initially
+        vbox.addWidget(self.generator_box)
 
         # Env box
         self.env_box = BadgerEnvBox(None, self.envs)
@@ -94,11 +134,11 @@ def init_ui(self):
         vbox.addStretch()
 
     def config_logic(self):
-        self.algo_box.cb.currentIndexChanged.connect(self.select_algo)
-        self.algo_box.btn_docs.clicked.connect(self.open_algo_docs)
-        self.algo_box.check_use_script.stateChanged.connect(self.toggle_use_script)
-        self.algo_box.btn_edit_script.clicked.connect(self.edit_script)
-        self.algo_box.cb_scaling.currentIndexChanged.connect(self.select_scaling_func)
+        self.btn_descr_update.clicked.connect(self.update_description)
+        self.generator_box.cb.currentIndexChanged.connect(self.select_generator)
+        self.generator_box.btn_docs.clicked.connect(self.open_generator_docs)
+        self.generator_box.check_use_script.stateChanged.connect(self.toggle_use_script)
+        self.generator_box.btn_edit_script.clicked.connect(self.edit_script)
         self.env_box.cb.currentIndexChanged.connect(self.select_env)
         self.env_box.btn_env_play.clicked.connect(self.open_playground)
         self.env_box.btn_add_var.clicked.connect(self.add_var)
@@ -109,18 +149,18 @@ def config_logic(self):
         self.env_box.btn_clear.clicked.connect(self.clear_init_table)
         self.env_box.btn_add_row.clicked.connect(self.add_row_to_init_table)
 
-    def refresh_ui(self, routine):
+    def refresh_ui(self, routine: Routine = None):
         self.routine = routine  # save routine for future reference
 
-        self.algos = list_algo()
+        self.generators = list_generators()
         self.envs = list_env()
         # Clean up the constraints/states list
         self.env_box.list_con.clear()
         self.env_box.list_sta.clear()
 
         if routine is None:
-            # Reset the algo and env configs
-            self.algo_box.cb.setCurrentIndex(-1)
+            # Reset the generator and env configs
+            self.generator_box.cb.setCurrentIndex(-1)
             self.env_box.cb.setCurrentIndex(-1)
 
             # Reset the routine configs check box status
@@ -131,138 +171,103 @@ def refresh_ui(self, routine):
             name = generate_slug(2)
             self.edit_save.setText('')
             self.edit_save.setPlaceholderText(name)
+            self.edit_descr.setPlainText('')
+            self.btn_descr_update.setDisabled(True)
 
             return
 
-        # Fill in the algo and env configs
-        name_algo = routine['algo']
-        idx_algo = self.algos.index(name_algo)
-        self.algo_box.cb.setCurrentIndex(idx_algo)
-        self.algo_box.edit.setPlainText(ystring(routine['algo_params']))
-        try:
-            self.script = routine['config']['script']
-        except KeyError:
-            self.script = None
-        try:
-            name_scaling = routine['config']['domain_scaling']['func']
-            params_scaling = routine['config']['domain_scaling'].copy()
-            del params_scaling['func']
-        except:
-            name_scaling = None
-            params_scaling = None
-        try:
-            idx_scaling = self.scaling_functions.index(name_scaling)
-        except ValueError:
-            idx_scaling = -1
-        self.algo_box.cb_scaling.setCurrentIndex(idx_scaling)
-        self.algo_box.edit_scaling.setPlainText(ystring(params_scaling))
-
-        name_env = routine['env']
+        # Enable description edition
+        self.btn_descr_update.setDisabled(False)
+        # Fill in the generator and env configs
+        name_generator = routine.generator.name
+        idx_generator = self.generators.index(name_generator)
+        self.generator_box.cb.setCurrentIndex(idx_generator)
+        # self.generator_box.edit.setPlainText(routine.generator.yaml())
+        self.generator_box.edit.setPlainText(
+            get_yaml_string(routine.generator.model_dump()))
+        self.script = routine.script
+
+        name_env = routine.environment.name
         idx_env = self.envs.index(name_env)
         self.env_box.cb.setCurrentIndex(idx_env)
-        self.env_box.edit.setPlainText(ystring(routine['env_params']))
+        env_params = routine.environment.model_dump()
+        del env_params["interface"]
+        self.env_box.edit.setPlainText(get_yaml_string(env_params))
 
         # Config the vocs panel
-        variables = [next(iter(v)) for v in routine['config']['variables']]
+        variables = routine.vocs.variable_names
         self.env_box.check_only_var.setChecked(True)
         self.env_box.edit_var.clear()
         self.env_box.var_table.set_selected(variables)
-        self.env_box.var_table.set_bounds(routine['config']['variables'])
+        self.env_box.var_table.set_bounds(routine.vocs.variables)
 
         try:
-            init_points = routine['config']['init_points']
+            init_points = routine.initial_points
             set_init_data_table(self.env_box.init_table, init_points)
         except KeyError:
             set_init_data_table(self.env_box.init_table, None)
 
-        objectives = [next(iter(v)) for v in routine['config']['objectives']]
+        objectives = routine.vocs.objective_names
         self.env_box.check_only_obj.setChecked(True)
         self.env_box.edit_obj.clear()
         self.env_box.obj_table.set_selected(objectives)
-        self.env_box.obj_table.set_rules(routine['config']['objectives'])
-
-        if routine['config']['constraints'] is not None:
-            for i in range(len(routine['config']['constraints'])):
-                name = next(iter(routine['config']['constraints'][i]))
-                relation, thres = routine['config']['constraints'][i][name][:2]
-                try:
-                    routine['config']['constraints'][i][name][2]
-                    critical = True
-                except:
-                    critical = False
+        self.env_box.obj_table.set_rules(routine.vocs.objectives)
+
+        constraints = routine.vocs.constraints
+        if len(constraints):
+            for name, val in constraints.items():
+                relation, thres = val
+                critical = name in routine.critical_constraint_names
                 relation = ['GREATER_THAN', 'LESS_THAN',
                             'EQUAL_TO'].index(relation)
                 self.add_constraint(name, relation, thres, critical)
 
-        try:
-            config_states = routine['config']['states']
-        except KeyError:
-            config_states = None
-        if config_states is not None:
-            for name_sta in config_states:
+        constants = routine.vocs.constants
+        if len(constants):
+            for name_sta, val in constants.items():
                 self.add_state(name_sta)
 
         # Config the metadata
-        name = routine['name']
         self.edit_save.setPlaceholderText(generate_slug(2))
-        self.edit_save.setText(name)
-        try:
-            tags = routine['config']['tags']
-        except:
-            tags = {}
-        try:
-            self.cbox_tags.cb_obj.setCurrentText(tags['objective'])
-        except:
-            self.cbox_tags.cb_obj.setCurrentIndex(0)
-        try:
-            self.cbox_tags.cb_reg.setCurrentText(tags['region'])
-        except:
-            self.cbox_tags.cb_reg.setCurrentIndex(0)
-        try:
-            self.cbox_tags.cb_gain.setCurrentText(tags['gain'])
-        except:
-            self.cbox_tags.cb_gain.setCurrentIndex(0)
+        self.edit_save.setText(routine.name)
+        self.edit_descr.setPlainText(routine.description)
 
-        self.algo_box.check_use_script.setChecked(not not self.script)
+        self.generator_box.check_use_script.setChecked(not not self.script)
 
-    def select_algo(self, i):
+    def select_generator(self, i):
         # Reset the script
         self.script = ''
-        self.algo_box.check_use_script.setChecked(False)
+        self.generator_box.check_use_script.setChecked(False)
 
         if i == -1:
-            self.algo_box.edit.setPlainText('')
-            self.algo_box.cb_scaling.setCurrentIndex(-1)
+            self.generator_box.edit.setPlainText('')
+            self.generator_box.cb_scaling.setCurrentIndex(-1)
             return
 
-        name = self.algos[i]
-        try:
-            _, configs = get_algo(name)
-            self.algo_box.edit.setPlainText(ystring(configs['params']))
+        name = self.generators[i]
+        default_config = get_generator_defaults(name)
+        self.generator_box.edit.setPlainText(get_yaml_string(default_config))
 
-            # Update the docs
-            self.window_docs.update_docs(name)
-        except Exception as e:
-            self.algo_box.cb.setCurrentIndex(-1)
-            return QMessageBox.critical(self, 'Error!', str(e))
+        # Update the docs
+        self.window_docs.update_docs(name)
 
     def toggle_use_script(self):
-        if self.algo_box.check_use_script.isChecked():
-            self.algo_box.btn_edit_script.show()
-            self.algo_box.edit.setReadOnly(True)
-            self.refresh_params_algo()
+        if self.generator_box.check_use_script.isChecked():
+            self.generator_box.btn_edit_script.show()
+            self.generator_box.edit.setReadOnly(True)
+            self.refresh_params_generator()
         else:
-            self.algo_box.btn_edit_script.hide()
-            self.algo_box.edit.setReadOnly(False)
+            self.generator_box.btn_edit_script.hide()
+            self.generator_box.edit.setReadOnly(False)
 
     def edit_script(self):
-        algo = self.algo_box.cb.currentText()
-        dlg = BadgerEditScriptDialog(self, algo, self.script, self.script_updated)
+        generator = self.generator_box.cb.currentText()
+        dlg = BadgerEditScriptDialog(self, generator, self.script, self.script_updated)
         dlg.exec()
 
     def script_updated(self, text):
         self.script = text
-        self.refresh_params_algo()
+        self.refresh_params_generator()
 
     def create_env(self):
         env_params = load_config(self.env_box.edit.toPlainText())
@@ -278,7 +283,7 @@ def create_env(self):
 
         return env
 
-    def refresh_params_algo(self):
+    def refresh_params_generator(self):
         if not self.script:
             return
 
@@ -298,24 +303,11 @@ def refresh_params_algo(self):
             except Exception:
                 vocs = None
             # Function generate comes from the script
-            params_algo = tmp['generate'](env, vocs)
-            self.algo_box.edit.setPlainText(ystring(params_algo))
+            params_generator = tmp['generate'](env, vocs)
+            self.generator_box.edit.setPlainText(get_yaml_string(params_generator))
         except Exception as e:
             QMessageBox.warning(self, 'Invalid script!', str(e))
 
-    def select_scaling_func(self, i):
-        if i == -1:
-            self.algo_box.edit_scaling.setPlainText('')
-            return
-
-        try:
-            name = self.scaling_functions[i]
-            default_params = get_scaling_default_params(name)
-            self.algo_box.edit_scaling.setPlainText(ystring(default_params))
-        except Exception as e:
-            self.algo_box.cb.setCurrentIndex(-1)
-            return QMessageBox.critical(self, 'Error!', str(e))
-
     def select_env(self, i):
         if i == -1:
             self.env_box.edit.setPlainText('')
@@ -343,9 +335,9 @@ def select_env(self, i):
             self.env_box.btn_add_sta.setDisabled(False)
             self.env_box.btn_add_var.setDisabled(False)
             self.env_box.btn_lim_vrange.setDisabled(False)
-            if self.algo_box.check_use_script.isChecked():
-                self.refresh_params_algo()
-        except Exception as e:
+            if self.generator_box.check_use_script.isChecked():
+                self.refresh_params_generator()
+        except:
             self.configs = None
             self.env = None
             self.env_box.cb.setCurrentIndex(-1)
@@ -354,23 +346,17 @@ def select_env(self, i):
             self.env_box.btn_add_var.setDisabled(True)
             self.env_box.btn_lim_vrange.setDisabled(True)
             self.routine = None
-            return QMessageBox.critical(self, 'Error!', str(e))
+            return QMessageBox.critical(
+                self,
+                'Error!',
+                traceback.format_exc()
+            )
 
-        self.env_box.edit.setPlainText(ystring(configs['params']))
+        self.env_box.edit.setPlainText(get_yaml_string(configs['params']))
 
         vars_env = configs['variables']
         vars_combine = [*vars_env]
-        if self.routine:  # check for the temp variables in vocs
-            vars_vocs = self.routine['config']['variables']
-            var_names_vocs = [next(iter(var)) for var in vars_vocs]
-            var_names_env = [next(iter(var)) for var in vars_env]
-            for name in var_names_vocs:
-                if name in var_names_env:
-                    continue
-
-                _var = {}
-                _var[name] = [-100, 100]  # TODO: how to get better default bounds?
-                vars_combine.append(_var)
+
         self.env_box.check_only_var.setChecked(False)
         self.env_box.var_table.update_variables(vars_combine)
 
@@ -386,7 +372,7 @@ def select_env(self, i):
         self.env_box.list_con.clear()
         self.env_box.list_sta.clear()
         self.env_box.fit_content()
-        self.routine = None
+        # self.routine = None
 
     def get_init_table_header(self):
         table = self.env_box.init_table
@@ -435,7 +421,7 @@ def add_row_to_init_table(self):
     def open_playground(self):
         pass
 
-    def open_algo_docs(self):
+    def open_generator_docs(self):
         self.window_docs.show()
 
     def add_var(self):
@@ -463,13 +449,13 @@ def limit_variable_ranges(self):
 
     def set_vrange(self):
         vname_selected = []
-        vrange = []
+        vrange = {}
 
         for var in self.env_box.var_table.all_variables:
             name = next(iter(var))
             if self.env_box.var_table.is_checked(name):
                 vname_selected.append(name)
-                vrange.append({name: var[name]})
+                vrange[name] = var[name]
 
         env = self.create_env()
         var_curr = env._get_variables(vname_selected)
@@ -478,20 +464,20 @@ def set_vrange(self):
         if option_idx:
             ratio = self.limit_option['ratio_full']
             for i, name in enumerate(vname_selected):
-                hard_bounds = vrange[i][name]
+                hard_bounds = vrange[name]
                 delta = 0.5 * ratio * (hard_bounds[1] - hard_bounds[0])
                 bounds = [var_curr[name] - delta, var_curr[name] + delta]
                 bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist()
-                vrange[i][name] = bounds
+                vrange[name] = bounds
         else:
             ratio = self.limit_option['ratio_curr']
             for i, name in enumerate(vname_selected):
-                hard_bounds = vrange[i][name]
+                hard_bounds = vrange[name]
                 sign = np.sign(var_curr[name])
                 bounds = [var_curr[name] * (1 - 0.5 * sign * ratio),
                           var_curr[name] * (1 + 0.5 * sign * ratio)]
                 bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist()
-                vrange[i][name] = bounds
+                vrange[name] = bounds
 
         self.env_box.var_table.set_bounds(vrange)
 
@@ -543,12 +529,13 @@ def add_state(self, name=None):
         self.env_box.list_sta.setItemWidget(item, sta_item)
         self.env_box.fit_content()
 
-    def _compose_vocs(self):
+    def _compose_vocs(self) -> (VOCS, List[str]):
         # Compose the VOCS settings
         variables = self.env_box.var_table.export_variables()
         objectives = self.env_box.obj_table.export_objectives()
 
-        constraints = []
+        constraints = {}
+        critical_constraints = []
         for i in range(self.env_box.list_con.count()):
             item = self.env_box.list_con.item(i)
             item_widget = self.env_box.list_con.itemWidget(item)
@@ -556,79 +543,44 @@ def _compose_vocs(self):
             con_name = item_widget.cb_obs.currentText()
             relation = CONS_RELATION_DICT[item_widget.cb_rel.currentText()]
             value = item_widget.sb.value()
-            _dict = {}
-            _dict[con_name] = [relation, value]
+            constraints[con_name] = [relation, value]
             if critical:
-                _dict[con_name].append('CRITICAL')
-            constraints.append(_dict)
+                critical_constraints.append(con_name)
 
-        states = []
+        states = {}
         for i in range(self.env_box.list_sta.count()):
-            item = self.env_box.list_sta.item(i)
-            item_widget = self.env_box.list_sta.itemWidget(item)
-            sta_name = item_widget.cb_sta.currentText()
-            states.append(sta_name)
-
-        vocs = {
-            'variables': variables,
-            'objectives': objectives,
-            'constraints': constraints,
-            'states': states,
-        }
+            raise NotImplementedError("constants/states has not been implemented yet!")
+            #item = self.env_box.list_sta.item(i)
+            #item_widget = self.env_box.list_sta.itemWidget(item)
+            #sta_name = item_widget.cb_sta.currentText()
+            #states[sta_name] =
+
+        vocs = VOCS(
+            variables=variables,
+            objectives=objectives,
+            constraints={},
+            constants={}
+        )
 
-        return vocs
+        return vocs, critical_constraints
 
-    def _compose_routine(self):
+    def _compose_routine(self) -> Routine:
         # Compose the routine
         name = self.edit_save.text() or self.edit_save.placeholderText()
-        algo = self.algo_box.cb.currentText()
-        assert algo, 'Please specify algorithm'
-        env = self.env_box.cb.currentText()
-        assert env, 'Please specify environment'
-        algo_params = load_config(self.algo_box.edit.toPlainText())
-        env_params = load_config(self.env_box.edit.toPlainText())
-
-        variables = self.env_box.var_table.export_variables()
-        objectives = self.env_box.obj_table.export_objectives()
-
-        constraints = []
-        for i in range(self.env_box.list_con.count()):
-            item = self.env_box.list_con.item(i)
-            item_widget = self.env_box.list_con.itemWidget(item)
-            critical = item_widget.check_crit.isChecked()
-            con_name = item_widget.cb_obs.currentText()
-            relation = CONS_RELATION_DICT[item_widget.cb_rel.currentText()]
-            value = item_widget.sb.value()
-            _dict = {}
-            _dict[con_name] = [relation, value]
-            if critical:
-                _dict[con_name].append('CRITICAL')
-            constraints.append(_dict)
+        description = self.edit_descr.toPlainText()
 
-        states = []
-        for i in range(self.env_box.list_sta.count()):
-            item = self.env_box.list_sta.item(i)
-            item_widget = self.env_box.list_sta.itemWidget(item)
-            sta_name = item_widget.cb_sta.currentText()
-            states.append(sta_name)
+        if self.generator_box.cb.currentIndex() == -1:
+            raise BadgerRoutineError("no generator selected")
+        if self.env_box.cb.currentIndex() == -1:
+            raise BadgerRoutineError("no environment selected")
 
-        # Domain scaling
-        try:
-            scaling = self.algo_box.cb_scaling.currentText()
-            scaling_params = load_config(self.algo_box.edit_scaling.toPlainText())
-            domain_scaling = {'func': scaling, **scaling_params}
-        except:
-            domain_scaling = None
+        generator_name = self.generators[self.generator_box.cb.currentIndex()]
+        env_name = self.envs[self.env_box.cb.currentIndex()]
+        generator_params = load_config(self.generator_box.edit.toPlainText())
+        env_params = load_config(self.env_box.edit.toPlainText())
 
-        # Tags
-        tag_obj = self.cbox_tags.cb_obj.currentText()
-        tag_reg = self.cbox_tags.cb_reg.currentText()
-        tag_gain = self.cbox_tags.cb_gain.currentText()
-        tags = {}
-        if tag_obj: tags['objective'] = tag_obj
-        if tag_reg: tags['region'] = tag_reg
-        if tag_gain: tags['gain'] = tag_gain
-        if not tags: tags = None
+        # VOCS
+        vocs, critical_constraints = self._compose_vocs()
 
         # Initial points
         init_points_df = pd.DataFrame.from_dict(
@@ -638,63 +590,79 @@ def _compose_routine(self):
                                                how='all')
         contains_na = init_points_df.isna().any().any()
         if contains_na:
-            raise BadgerRoutineError('Initial points are not valid, please fill in the missing values')
-        if init_points_df.empty:
-            init_points = None
-        else:
-            init_points_df = init_points_df.astype(float)
-            init_points = init_points_df.to_dict('list')
-
-        configs = {
-            'variables': variables,
-            'objectives': objectives,
-            'constraints': constraints,
-            'states': states,
-            'domain_scaling': domain_scaling,
-            'tags': tags,
-            'init_points': init_points,
-        }
-        if self.algo_box.check_use_script.isChecked():
-            configs['script'] = self.script
-
-        routine = {
-            'name': name,
-            'algo': algo,
-            'env': env,
-            'algo_params': algo_params,
-            'env_params': env_params,
-            'env_vranges': config_list_to_dict(variables),
-            'config': configs,
-        }
+            raise BadgerRoutineError(
+                'Initial points are not valid, please fill in the missing values'
+            )
 
-        # Sanity check and config normalization
-        try:
-            routine = normalize_routine(routine)
-        except Exception as e:
-            raise e
-
-        return routine
+        # Script that generates generator params
+        if self.generator_box.check_use_script.isChecked():
+            script = self.script
+        else:
+            script = None
+
+        return Routine(
+            # Xopt part
+            vocs=vocs,
+            generator={"name": generator_name} | generator_params,
+            # Badger part
+            name=name,
+            description=description,
+            environment={"name": env_name} | env_params,
+            initial_points=init_points_df.astype("double"),
+            critical_constraint_names=critical_constraints,
+            tags=None,
+            script=script,
+        )
 
     def review(self):
         try:
             routine = self._compose_routine()
-        except Exception as e:
-            return QMessageBox.critical(self, 'Error!', str(e))
+        except:
+            return QMessageBox.critical(
+                self,
+                'Invalid routine!',
+                traceback.format_exc()
+            )
 
         dlg = BadgerReviewDialog(self, routine)
         dlg.exec()
 
+    def update_description(self):
+        routine = self.routine
+        routine.description = self.edit_descr.toPlainText()
+        try:
+            update_routine(routine)
+            # Notify routine list to update
+            self.sig_updated.emit(routine.name, routine.description)
+            QMessageBox.information(
+                self,
+                'Update succeeded!',
+                f'Routine {self.routine.name} description was updated successfully!'
+            )
+        except Exception:
+            return QMessageBox.critical(
+                self,
+                'Update failed!',
+                traceback.format_exc()
+            )
+
     def save(self):
         try:
             routine = self._compose_routine()
-        except Exception as e:
-            return QMessageBox.critical(self, 'Error!', str(e))
+        except ValidationError:
+            return QMessageBox.critical(
+                self,
+                'Error!',
+                traceback.format_exc()
+            )
 
         try:
             save_routine(routine)
         except sqlite3.IntegrityError:
-            return QMessageBox.critical(self, 'Error!',
-                f'Routine {routine["name"]} already existed in the database! Please choose another name.')
+            return QMessageBox.critical(
+                self, 'Error!',
+                f'Routine {routine.name} already existed in the database! Please '
+                f'choose another name.')
 
         return 0
 
diff --git a/src/badger/gui/default/components/routine_runner.py b/src/badger/gui/default/components/routine_runner.py
index 438f3ea3..eea7f998 100644
--- a/src/badger/gui/default/components/routine_runner.py
+++ b/src/badger/gui/default/components/routine_runner.py
@@ -1,49 +1,51 @@
 import logging
+
 logger = logging.getLogger(__name__)
-import os
 import time
-import copy
-import pandas as pd
 from pandas import DataFrame
 from PyQt5.QtCore import pyqtSignal, QObject, QRunnable
-from xopt.generators import get_generator
-from ....utils import curr_ts, ts_to_str, merge_params
-from ....core import run_routine_xopt, instantiate_env, Routine
-from ....settings import read_value
-from ....archive import archive_run
+from ....core import run_routine, Routine
 from ....errors import BadgerRunTerminatedError
 
 
 class BadgerRoutineSignals(QObject):
     env_ready = pyqtSignal(list)
     finished = pyqtSignal()
-    progress = pyqtSignal(list, list, list, list, float)
+    progress = pyqtSignal()
     error = pyqtSignal(Exception)
     info = pyqtSignal(str)
 
 
 class BadgerRoutineRunner(QRunnable):
+    """
+        Seperate thread to run routine using code in core.py
+
+    """
+
+    def __init__(self, routine: Routine, save: bool, verbose=2, use_full_ts=False):
+        """
+        Parameters
+        ----------
+        routine: Routine
+            Defined routine for runner
+
+        save: bool
+            Flag to enable saving to database
+
+        verbose: int, default: 2
+            Verbostiy level (higher is more output)
 
-    def __init__(self, routine, save, verbose=2, use_full_ts=False):
+        use_full_ts: bool
+            If true use full time stamp info when dumping to database
+        """
         super().__init__()
 
         # Signals should belong to instance rather than class
-        # Since there could be multiple runners runing in parallel
+        # Since there could be multiple runners running in parallel
         self.signals = BadgerRoutineSignals()
 
         self.routine = routine
         self.run_filename = None
-        self.var_names = var_names = [next(iter(d)) for d in routine['config']['variables']]
-        self.obj_names = obj_names = [next(iter(d)) for d in routine['config']['objectives']]
-        if routine['config']['constraints']:
-            self.con_names = con_names = [next(iter(d)) for d in routine['config']['constraints']]
-        else:
-            self.con_names = con_names = []
-        try:
-            self.sta_names = sta_names = routine['config']['states'] or []
-        except KeyError:  # this would happen when rerun an old version routine
-            self.sta_names = sta_names = []
-        self.data = pd.DataFrame(None, columns=['timestamp_raw', 'timestamp'] + obj_names + con_names + var_names + sta_names)
         self.states = None  # system states to be saved at start of a run
         self.save = save
         self.verbose = verbose
@@ -58,30 +60,30 @@ def __init__(self, routine, save, verbose=2, use_full_ts=False):
     def set_termination_condition(self, termination_condition):
         self.termination_condition = termination_condition
 
-    def run(self):
+    def run(self) -> None:
         self.start_time = time.time()
         self.last_dump_time = None  # reset the timer
 
         try:
-            # run_routine(self.routine, True, self.save, self.verbose,
-            #             self.before_evaluate, self.after_evaluate,
-            #             self.env_ready, self.pf_ready, self.states_ready)
-            routine_xopt = self.get_routine_xopt()
-            run_routine_xopt(routine_xopt,
-                             active_callback=self.run_status,
-                             generate_callback=self.before_evaluate_xopt,
-                             evaluate_callback=self.after_evaluate_xopt,
-                             pf_callback=self.pf_ready,
-                             states_callback=self.states_ready)
+            self.save_init_vars()
+
+            self.routine.data = None  # reset data
+            run_routine(
+                self.routine,
+                active_callback=self.check_run_status,
+                generate_callback=self.before_evaluate,
+                evaluate_callback=self.after_evaluate,
+                states_callback=self.states_ready
+            )
         except BadgerRunTerminatedError as e:
             self.signals.finished.emit()
             self.signals.info.emit(str(e))
         except Exception as e:
-            logger.exception(e)
+            print(e)
             self.signals.finished.emit()
             self.signals.error.emit(e)
 
-    def before_evaluate(self, vars):
+    def before_evaluate(self, candidates: DataFrame):
         # vars: ndarray
         while self.is_paused:
             time.sleep(0)
@@ -91,145 +93,61 @@ def before_evaluate(self, vars):
         if self.is_killed:
             raise BadgerRunTerminatedError
 
-    def after_evaluate(self, vars, obses, cons, stas):
-        # vars: ndarray
-        # obses: ndarray
-        # cons: ndarray
-        # stas: list
-        ts = curr_ts()
-        ts_float = ts.timestamp()
-        self.signals.progress.emit(list(vars), list(obses), list(cons), list(stas), ts_float)
-
-        # Append solution to data
-        fmt = 'lcls-log-full' if self.use_full_ts else 'lcls-log'
-        solution = [ts.timestamp(), ts_to_str(ts, fmt)] + list(obses) + list(cons) + list(vars) + list(stas)
-        new_row = pd.Series(solution, index=self.data.columns)
-        self.data = pd.concat([self.data, new_row.to_frame().T], ignore_index=True)
+    def after_evaluate(self, data: DataFrame):
+        self.signals.progress.emit()
 
         # Try dump the run data and interface log to the disk
-        dump_period = float(read_value('BADGER_DATA_DUMP_PERIOD'))
-        if (self.last_dump_time is None) or (ts_float - self.last_dump_time > dump_period):
-            self.last_dump_time = ts_float
-            run = archive_run(self.routine, self.data, self.states)
-            try:
-                path = run['path']
-                filename = run['filename'][:-4] + 'pickle'
-                self.env.interface.stop_recording(os.path.join(path, filename))
-            except:
-                pass
-
-        # Take a break to let the outside signal to change the status
+        # dump_period = float(read_value('BADGER_DATA_DUMP_PERIOD'))
+        # if (self.last_dump_time is None) or (
+        #         ts_float - self.last_dump_time > dump_period):
+        #     self.last_dump_time = ts_float
+        #     run = archive_run(self.routine, self.states)
+        #     try:
+        #         path = run['path']
+        #         filename = run['filename'][:-4] + 'pickle'
+        #         self.env.interface.stop_recording(os.path.join(path, filename))
+        #     except:
+        #         pass
+
+        # Take a break to let the outside signal change the status
         time.sleep(0.1)
 
-        # Check if termination condition has been satisfied
-        if self.termination_condition is None:
-            return
-
-        tc_config = self.termination_condition
-        idx = tc_config['tc_idx']
-        if idx == 0:
-            max_eval = tc_config['max_eval']
-            if self.data.shape[0] >= max_eval:
-                raise BadgerRunTerminatedError
+    def check_run_status(self):
+        """
+        check for termination condition
 
-        elif idx == 1:
-            max_time = tc_config['max_time']
-            dt = time.time() - self.start_time
-            if dt >= max_time:
-                raise BadgerRunTerminatedError
-        # elif idx == 2:
-        #     ftol = tc_config['ftol']
-        #     # Do something
+        - checks for internal triggers (max eval, max time) and external triggers
 
-    def run_status(self):
+        """
+        # Check if termination condition has been satisfied
+        if self.termination_condition:
+            tc_config = self.termination_condition
+            idx = tc_config['tc_idx']
+            if idx == 0:
+                max_eval = tc_config['max_eval']
+                if len(self.routine.data) >= max_eval:
+                    return 2
+
+            elif idx == 1:
+                max_time = tc_config['max_time']
+                dt = time.time() - self.start_time
+                if dt >= max_time:
+                    return 2
+
+        # External triggers
         if self.is_killed:
             return 2
         elif self.is_paused:
             return 1
         else:
-            return 0  # running
-
-    def get_routine_xopt(self):
-        routine = self.routine
-
-        from ....factory import get_env
-
-        # Initialize routine
-        Environment, configs_env = get_env(routine['env'])
-        _configs_env = merge_params(
-            configs_env, {'params': routine['env_params']})
-        environment = instantiate_env(Environment, _configs_env)
-        self.env_ready(environment)
-
-        variables = {key: value for dictionary in routine['config']['variables']
-                     for key, value in dictionary.items()}
-        objectives = {key: value for dictionary in routine['config']['objectives']
-                      for key, value in dictionary.items()}
-        # TODO: consider critical ones
-        if routine['config']['constraints'] is not None:
-            constraints = {key: value[:2] for dictionary in routine['config']['constraints']
-                           for key, value in dictionary.items()}
-        else:
-            constraints = {}
-        states = routine['config']['states'] or []
-        vocs = {
-            'variables': variables,
-            'objectives': objectives,
-            'constraints': constraints,
-            'observables': states,
-        }
-        generator_class = get_generator(routine['algo'])
-        try:
-            del routine['algo_params']['start_from_current']
-        except KeyError:
-            pass
-        routine_copy = copy.deepcopy(routine['algo_params'])
-        # Note! The following line will remove all the name fields in
-        # generator params. That's why we make a copy here so the modification
-        # will not affect the routine to be saved (in archive)
-        generator = generator_class(vocs=vocs, **routine_copy)
+            return 0  # continue to run
 
-        try:
-            initial_points = routine['config']['init_points']
-            initial_points = DataFrame.from_dict(initial_points)
-            if initial_points.empty:
-                raise KeyError
-        except KeyError:  # start from current
-            initial_points = environment.get_variables(generator.vocs.variable_names)
-            initial_points = DataFrame(initial_points, index=[0])
-
-        routine_xopt = Routine(environment=environment, generator=generator,
-                               initial_points=initial_points)
-
-        return routine_xopt
-
-    def before_evaluate_xopt(self, candidates: DataFrame):
-        pass
-
-    def after_evaluate_xopt(self, data: DataFrame):
-        vars = data[self.var_names].to_numpy()[0]
-        obses = data[self.obj_names].to_numpy()[0]
-        cons = data[self.con_names].to_numpy()
-        try:
-            cons = cons[0]
-        except IndexError:
-            pass
-        stas = data[self.sta_names].to_numpy()
-        try:
-            stas = stas[0]
-        except IndexError:
-            pass
-        self.after_evaluate(vars, obses, cons, stas)
-
-    def env_ready(self, env):
-        self.env = env
-        var_dict = env._get_variables(self.var_names)
-        init_vars = [var_dict[v] for v in self.var_names]
+    def save_init_vars(self):
+        var_names = self.routine.vocs.variable_names
+        var_dict = self.routine.environment._get_variables(var_names)
+        init_vars = list(var_dict.values())
         self.signals.env_ready.emit(init_vars)
 
-    def pf_ready(self, pf):
-        self.pf = pf
-
     def states_ready(self, states):
         self.states = states
 
diff --git a/src/badger/gui/default/components/run_monitor.py b/src/badger/gui/default/components/run_monitor.py
index 830c6fd0..ab79c650 100644
--- a/src/badger/gui/default/components/run_monitor.py
+++ b/src/badger/gui/default/components/run_monitor.py
@@ -1,19 +1,28 @@
 import os
+import traceback
 from importlib import resources
 import numpy as np
+import pandas as pd
 from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QCheckBox
 from PyQt5.QtWidgets import QMessageBox, QComboBox, QLabel, QStyledItemDelegate
 from PyQt5.QtWidgets import QToolButton, QMenu, QAction
 from PyQt5.QtCore import pyqtSignal, QThreadPool, QSize
 from PyQt5.QtGui import QFont, QIcon
 import pyqtgraph as pg
+from xopt import VOCS
+
+from .extensions_palette import ExtensionsPalette
 from .routine_runner import BadgerRoutineRunner
+from ..utils import create_button
 from ..windows.terminition_condition_dialog import BadgerTerminationConditionDialog
+from ....routine import Routine
 # from ...utils import AURORA_PALETTE, FROST_PALETTE
-from ....utils import norm, ParetoFront
 from ....logbook import send_to_logbook, BADGER_LOGBOOK_ROOT
 from ....archive import archive_run, BADGER_ARCHIVE_ROOT
 
+# disable chained assignment warning from pydantic
+pd.options.mode.chained_assignment = None  # default='warn'
+
 
 stylesheet_del = '''
 QPushButton:hover:pressed
@@ -33,7 +42,7 @@
 stylesheet_log = '''
 QPushButton:hover:pressed
 {
-    background-color: #5C8899;
+    background-color: #88C0D0;
 }
 QPushButton:hover
 {
@@ -41,11 +50,26 @@
 }
 QPushButton
 {
-    background-color: #88C0D0;
+    background-color: #5C8899;
     color: #000000;
 }
 '''
 
+stylesheet_ext = '''
+QPushButton:hover:pressed
+{
+    background-color: #4DB6AC;
+}
+QPushButton:hover
+{
+    background-color: #26A69A;
+}
+QPushButton
+{
+    background-color: #00897B;
+}
+'''
+
 stylesheet_run = '''
 QToolButton:hover:pressed
 {
@@ -85,7 +109,7 @@ class BadgerOptMonitor(QWidget):
     sig_new_run = pyqtSignal()
     sig_run_name = pyqtSignal(str)  # filename of the new run
     sig_inspect = pyqtSignal(int)  # index of the inspector
-    sig_progress = pyqtSignal(list, list, list, list)  # new evaluated solution
+    sig_progress = pyqtSignal(pd.DataFrame)  # new evaluated solution
     sig_del = pyqtSignal()
 
     def __init__(self):
@@ -98,36 +122,37 @@ def __init__(self):
         self.x_plot_relative = True
         # Routine info
         self.routine = None
-        self.var_names = []
-        self.obj_names = []
-        self.con_names = []
-        self.sta_names = []
-        self.vranges = []
+
         # Curves in the monitor
-        self.curves_var = []
-        self.curves_obj = []
-        self.curves_con = []
-        self.curves_sta = []
-        # Data to be visualized
-        self.vars = []
-        self.objs = []
-        self.cons = []
-        self.stas = []
-        self.ts = []
+        self.curves_variable = {}
+        self.curves_objective = {}
+        self.curves_constraint = {}
+        self.curves_sta = {}
+
         # Run optimization
         self.thread_pool = None
         self.routine_runner = None
         self.running = False
-        # Analysis tool for history runs
-        self.pf = None
         # Fix the auto range issue
         self.eval_count = 0
         # Termination condition for the run
         self.termination_condition = None
 
+        self.extensions_palette = ExtensionsPalette(self)
+        self.active_extensions = []
+
+        self.testing = False
+        self.tc_dialog = None
+
+        self.post_run_actions = []
+
         self.init_ui()
         self.config_logic()
 
+    @property
+    def vocs(self) -> VOCS:
+        return self.routine.vocs
+
     def init_ui(self):
         # Load all icons
         icon_ref = resources.files(__package__) / '../images/play.png'
@@ -139,27 +164,6 @@ def init_ui(self):
         icon_ref = resources.files(__package__) / '../images/stop.png'
         with resources.as_file(icon_ref) as icon_path:
             self.icon_stop = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/undo.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_reset = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/set.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_set = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/book.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_log = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/trash.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_del = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/star.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_opt = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/tools.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_config = QIcon(str(icon_path))
-        icon_ref = resources.files(__package__) / '../images/info.png'
-        with resources.as_file(icon_ref) as icon_path:
-            self.icon_info = QIcon(str(icon_path))
 
         # self.main_panel = main_panel = QWidget(self)
         # main_panel.setStyleSheet('background-color: #19232D;')
@@ -196,58 +200,30 @@ def init_ui(self):
         # monitor.ci.setBorder((50, 50, 100))
         # monitor.resize(1000, 600)
 
-        self.plot_obj = plot_obj = monitor.addPlot(
-            title='Evaluation History (Y)')
-        plot_obj.setLabel('left', 'objectives')
-        plot_obj.setLabel('bottom', 'iterations')
-        plot_obj.showGrid(x=True, y=True)
-        leg_obj = plot_obj.addLegend()
-        leg_obj.setBrush((50, 50, 100, 200))
+        # create vertical cursor lines
+        self.inspector_objective = create_cursor_line()
+        self.inspector_constraint = create_cursor_line()
+        self.inspector_state = create_cursor_line()
+        self.inspector_variable = create_cursor_line()
+
+        # add axes
+        self.plot_obj = plot_obj = add_axes(
+            monitor, "objectives", 'Evaluation History (Y)', self.inspector_objective
+        )
 
         monitor.nextRow()  # leave space for the cons plot
         monitor.nextRow()  # leave space for the stas plot
         monitor.nextRow()
 
-        self.plot_var = plot_var = monitor.addPlot(
-            title='Evaluation History (X)')
-        plot_var.setLabel('left', 'variables')
-        plot_var.setLabel('bottom', 'iterations')
-        plot_var.showGrid(x=True, y=True)
-        leg_var = plot_var.addLegend()
-        leg_var.setBrush((50, 50, 100, 200))
+        self.plot_var = plot_var = add_axes(
+            monitor, "variables", "Evaluation History (X)", self.inspector_variable
+        )
 
         plot_var.setXLink(plot_obj)
 
         self.colors = ['c', 'g', 'm', 'y', 'b', 'r', 'w']
         self.symbols = ['o', 't', 't1', 's', 'p', 'h', 'd']
 
-        self.ins_obj = pg.InfiniteLine(movable=True, angle=90, label=None,
-                                       labelOpts={
-                                           'position': 0.1,
-                                           'color': (200, 200, 100),
-                                           'fill': (200, 200, 200, 50),
-                                           'movable': True})
-        self.ins_con = pg.InfiniteLine(movable=True, angle=90, label=None,
-                                       labelOpts={
-                                           'position': 0.1,
-                                           'color': (200, 200, 100),
-                                           'fill': (200, 200, 200, 50),
-                                           'movable': True})
-        self.ins_sta = pg.InfiniteLine(movable=True, angle=90, label=None,
-                                       labelOpts={
-                                           'position': 0.1,
-                                           'color': (200, 200, 100),
-                                           'fill': (200, 200, 200, 50),
-                                           'movable': True})
-        self.ins_var = pg.InfiniteLine(movable=True, angle=90, label=None,
-                                       labelOpts={
-                                           'position': 0.1,
-                                           'color': (200, 200, 100),
-                                           'fill': (200, 200, 200, 50),
-                                           'movable': True})
-        plot_obj.addItem(self.ins_obj)
-        plot_var.addItem(self.ins_var)
-
         # Action bar
         action_bar = QWidget()
         # action_bar.hide()
@@ -258,46 +234,24 @@ def init_ui(self):
         cool_font.setWeight(QFont.DemiBold)
         cool_font.setPixelSize(13)
 
-        self.btn_del = btn_del = QPushButton()
-        btn_del.setFixedSize(32, 32)
-        btn_del.setIcon(self.icon_del)
-        btn_del.setToolTip('Delete run')
-        btn_del.setStyleSheet(stylesheet_del)
-        # self.btn_edit = btn_edit = QPushButton('Edit')
-        # btn_edit.setFixedSize(64, 32)
-        # btn_edit.setFont(cool_font)
-        self.btn_log = btn_log = QPushButton()
-        btn_log.setFixedSize(32, 32)
-        btn_log.setIcon(self.icon_log)
-        btn_log.setToolTip('Logbook')
-        btn_log.setStyleSheet(stylesheet_log)
-        self.btn_reset = btn_reset = QPushButton()
-        btn_reset.setDisabled(True)
-        btn_reset.setFixedSize(32, 32)
-        btn_reset.setIcon(self.icon_reset)
-        btn_reset.setToolTip('Reset environment')
-        self.btn_opt = btn_opt = QPushButton()
-        btn_opt.setFixedSize(32, 32)
-        btn_opt.setIcon(self.icon_opt)
-        btn_opt.setToolTip('Jump to optimum')
-        self.btn_set = btn_set = QPushButton()
-        btn_set.setDisabled(True)
-        btn_set.setFixedSize(32, 32)
-        btn_set.setIcon(self.icon_set)
-        btn_set.setToolTip('Dial in solution')
-        self.btn_ctrl = btn_ctrl = QPushButton()
-        btn_ctrl.setDisabled(True)
-        btn_ctrl.setFixedSize(32, 32)
-        btn_ctrl.setIcon(self.icon_pause)
-        btn_ctrl.setToolTip('Pause')
-        btn_ctrl._status = 'pause'
+        self.btn_del = create_button("trash.png", "Delete run", stylesheet_del)
+        self.btn_log = create_button("book.png", "Logbook", stylesheet_log)
+        self.btn_reset = create_button("undo.png", "Reset environment")
+        self.btn_opt = create_button("star.png", "Jump to optimum")
+        self.btn_set = create_button("set.png", "Dial in solution")
+        self.btn_ctrl = create_button("pause.png", "Pause")
+        self.btn_ctrl._status = 'pause'
+        self.btn_ctrl.setDisabled(True)
 
         # self.btn_stop = btn_stop = QPushButton('Run')
-        self.btn_stop = btn_stop = QToolButton()
-        btn_stop.setDisabled(True)
-        btn_stop.setFixedSize(96, 32)
-        btn_stop.setFont(cool_font)
-        btn_stop.setStyleSheet(stylesheet_run)
+        self.btn_stop = QToolButton()
+        self.btn_stop.setFixedSize(96, 32)
+        self.btn_stop.setFont(cool_font)
+        self.btn_stop.setStyleSheet(stylesheet_run)
+
+        # add button for extensions
+        self.btn_open_extensions_palette = btn_extensions = create_button(
+            "extension.png", "Open extensions", stylesheet_ext)
 
         # Create a menu and add options
         self.run_menu = menu = QMenu(self)
@@ -310,34 +264,31 @@ def init_ui(self):
         menu.addAction(run_until_action)
 
         # Set the menu as the run button's dropdown menu
-        btn_stop.setMenu(menu)
-        btn_stop.setDefaultAction(run_action)
-        btn_stop.setPopupMode(QToolButton.MenuButtonPopup)
+        self.btn_stop.setMenu(menu)
+        self.btn_stop.setDefaultAction(run_action)
+        self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup)
+        self.btn_stop.setDisabled(True)
         # btn_stop.setToolTip('')
 
         # Config button
-        self.btn_config = btn_config = QPushButton()
-        btn_config.setFixedSize(32, 32)
-        btn_config.setIcon(self.icon_config)
-        btn_config.setToolTip('Configure run')
-        # btn_config.setIconSize(QSize(24, 24))
-
+        self.btn_config = btn_config = create_button("tools.png", "Configure run")
+        btn_config.hide()
         # Run info button
-        self.btn_info = btn_info = QPushButton()
-        btn_info.setFixedSize(32, 32)
-        btn_info.setIcon(self.icon_info)
-        btn_info.setToolTip('Run information')
+        self.btn_info = btn_info = create_button("info.png", "Run information")
+        btn_info.hide()
 
-        hbox_action.addWidget(btn_del)
+        hbox_action.addWidget(self.btn_del)
         # hbox_action.addWidget(btn_edit)
-        hbox_action.addWidget(btn_log)
+        hbox_action.addWidget(self.btn_log)
         hbox_action.addStretch(1)
-        hbox_action.addWidget(btn_reset)
-        hbox_action.addWidget(btn_ctrl)
-        hbox_action.addWidget(btn_stop)
-        hbox_action.addWidget(btn_opt)
-        hbox_action.addWidget(btn_set)
+        hbox_action.addWidget(self.btn_opt)
+        hbox_action.addWidget(self.btn_reset)
+        hbox_action.addWidget(self.btn_ctrl)
+        hbox_action.addWidget(self.btn_stop)
+        hbox_action.addWidget(self.btn_opt)
+        hbox_action.addWidget(self.btn_set)
         hbox_action.addStretch(1)
+        hbox_action.addWidget(btn_extensions)
         hbox_action.addWidget(btn_config)
         hbox_action.addWidget(btn_info)
 
@@ -345,16 +296,41 @@ def init_ui(self):
         vbox.addWidget(monitor)
         vbox.addWidget(action_bar)
 
+    # noinspection PyUnresolvedReferences
     def config_logic(self):
+        """
+        Configure the logic and connections for various interactive elements in the
+        application.
+
+        This method sets up event connections and handlers for different interactive
+        elements in the application, such as the inspector lines, buttons,
+        and visualizations. It establishes connections between signals and slots to
+        enable user interaction and control of the application's functionality.
+
+        Notes
+        -----
+        - The `config_logic` method is intended to be called once during
+        the initialization of the application or a specific class. It sets up various
+        event handlers and connections for interactive elements.
+
+        - The signals and slots established in this method determine how the
+        application responds to user actions, such as button clicks, inspector line
+        drags, and selection changes in visualization controls.
+
+        - Ensure that the necessary attributes and dependencies are properly
+        initialized before calling this method.
+
+        """
+
         # Sync the inspector lines
-        self.ins_obj.sigDragged.connect(self.ins_obj_dragged)
-        self.ins_obj.sigPositionChangeFinished.connect(self.ins_drag_done)
-        self.ins_con.sigDragged.connect(self.ins_con_dragged)
-        self.ins_con.sigPositionChangeFinished.connect(self.ins_drag_done)
-        self.ins_sta.sigDragged.connect(self.ins_sta_dragged)
-        self.ins_sta.sigPositionChangeFinished.connect(self.ins_drag_done)
-        self.ins_var.sigDragged.connect(self.ins_var_dragged)
-        self.ins_var.sigPositionChangeFinished.connect(self.ins_drag_done)
+        self.inspector_objective.sigDragged.connect(self.ins_obj_dragged)
+        self.inspector_objective.sigPositionChangeFinished.connect(self.ins_drag_done)
+        self.inspector_constraint.sigDragged.connect(self.ins_con_dragged)
+        self.inspector_constraint.sigPositionChangeFinished.connect(self.ins_drag_done)
+        self.inspector_state.sigDragged.connect(self.ins_sta_dragged)
+        self.inspector_state.sigPositionChangeFinished.connect(self.ins_drag_done)
+        self.inspector_variable.sigDragged.connect(self.ins_var_dragged)
+        self.inspector_variable.sigPositionChangeFinished.connect(self.ins_drag_done)
         self.plot_obj.scene().sigMouseClicked.connect(self.on_mouse_click)
         # sigMouseReleased.connect(self.on_mouse_click)
 
@@ -369,6 +345,7 @@ def config_logic(self):
         self.btn_ctrl.clicked.connect(self.ctrl_routine)
         self.run_action.triggered.connect(self.set_run_action)
         self.run_until_action.triggered.connect(self.set_run_until_action)
+        self.btn_open_extensions_palette.clicked.connect(self.open_extensions_palette)
 
         # Visualization
         self.cb_plot_x.currentIndexChanged.connect(self.select_x_axis)
@@ -382,193 +359,196 @@ def config_logic(self):
     #         print('Yo')
     #         self.sender().showMenu()
 
-    def init_plots(self, routine, data=None, run_filename=None):
-        # Parse routine
+    def init_plots(self, routine: Routine = None, run_filename: str = None):
+        """
+        Initialize and configure the plots and related components in the application.
+
+        This method initializes and configures the plot areas, curves, and inspectors
+        used for visualizing data in the application. It also manages the state of
+        various UI elements based on the provided routine and run information.
+
+        Parameters
+        ----------
+        routine : Routine,
+            The routine to use for configuring the plots. If
+            not provided, the method will use the previously set routine.
+
+        run_filename : str, optional
+            The filename of the run, used to determine the state of the application's UI
+            elements.
+
+        Returns
+        -------
+        None
+
+        Notes
+        -----
+        - The `init_plots` method is typically called during the application's
+        initialization or when a new routine is selected. It sets up
+        the plots, curves, and inspectors for visualizing data.
+
+        - The method relies on the `self.routine`, `self.vocs`, `self.monitor`,
+        `self.colors`, `self.symbols`, and other attributes of the class to configure
+        the plots and manage UI elements.
+
+        - The `routine` parameter allows you to provide a specific routine for
+        configuring the plots. If not provided, it will use the previously set routine.
+
+        - The `run_filename` parameter is used to determine the state of UI elements,
+        such as enabling or disabling certain buttons based on whether the run data
+        is available.
+
+        """
+        if routine is None:
+            # if no routines are specified, clear the current plots
+            self.plot_var.clear()
+            self.plot_obj.clear()
+
+            # if constraints are active clear them
+            try:
+                self.plot_con.clear()
+                self.plot_con.addItem(self.inspector_constraint)
+            except AttributeError:
+                pass
+
+            # if statics exist clear that plot
+            try:
+                self.plot_sta.clear()
+                self.plot_sta.addItem(self.inspector_state)
+            except AttributeError:
+                pass
+
+            # if no routine is loaded set button to disabled
+            self.btn_del.setDisabled(True)
+            self.btn_log.setDisabled(True)
+            self.btn_reset.setDisabled(True)
+            self.btn_ctrl.setDisabled(True)
+            self.btn_stop.setDisabled(True)
+            self.btn_opt.setDisabled(True)
+            self.btn_set.setDisabled(True)
+
+            return
+
         self.routine = routine
-        try:
-            self.obj_names = [next(iter(d))
-                              for d in self.routine['config']['objectives']]
-        except:
-            self.obj_names = []
-        try:
-            self.var_names = [next(iter(d))
-                              for d in self.routine['config']['variables']]
-        except:
-            self.var_names = []
-        try:
-            self.vranges = np.array([d[next(iter(d))]
-                                    for d in routine['config']['variables']])
-        except:
-            self.vranges = []
-        try:
-            if self.routine['config']['constraints']:
-                self.con_names = [next(iter(d))
-                                  for d in self.routine['config']['constraints']]
-            else:
-                self.con_names = []
-        except:
-            self.con_names = []
-        try:
-            self.sta_names = self.routine['config']['states'] or []
-        except KeyError:  # this would happen when rerun an old version routine
-            self.sta_names = []
-
-        # Configure plots
-        # Clear current plots
-        self.plot_obj.clear()
-        self.plot_obj.addItem(self.ins_obj)
-        try:
-            self.plot_con.clear()
-            self.plot_con.addItem(self.ins_con)
-        except:
-            pass
-        try:
-            self.plot_sta.clear()
-            self.plot_sta.addItem(self.ins_sta)
-        except:
-            pass
-        self.plot_var.clear()
-        self.plot_var.addItem(self.ins_var)
-        # Put in the empty curves
-        self.curves_var = []
-        self.curves_obj = []
-        self.curves_con = []
-        self.curves_sta = []
-
-        for i, obj_name in enumerate(self.obj_names):
-            color = self.colors[i % len(self.colors)]
-            symbol = self.symbols[i % len(self.colors)]
-            _curve = self.plot_obj.plot(pen=pg.mkPen(color, width=3),
-                                        # symbol=symbol,
-                                        name=obj_name)
-            self.curves_obj.append(_curve)
 
-        for i, var_name in enumerate(self.var_names):
-            color = self.colors[i % len(self.colors)]
-            symbol = self.symbols[i % len(self.colors)]
-            _curve = self.plot_var.plot(pen=pg.mkPen(color, width=3),
-                                        # symbol=symbol,
-                                        name=var_name)
-            self.curves_var.append(_curve)
+        # Retrieve data information
+        objective_names = self.vocs.objective_names
+        variable_names = self.vocs.variable_names
+        constraint_names = self.vocs.constraint_names
+        sta_names = self.vocs.constant_names
 
-        if self.con_names:
+        # Configure variable plots
+        self.curves_variable = self._configure_plot(
+            self.plot_var, self.inspector_variable, variable_names
+        )
+
+        # Configure objective plots
+        self.curves_objective = self._configure_plot(
+            self.plot_obj, self.inspector_objective, objective_names
+        )
+
+        # Configure constraint plots
+        if constraint_names:
             try:
                 self.plot_con
             except:
-                self.plot_con = plot_con = self.monitor.addPlot(
-                    row=1, col=0, title='Evaluation History (C)')
-                plot_con.setLabel('left', 'constraints')
-                plot_con.setLabel('bottom', 'iterations')
-                plot_con.showGrid(x=True, y=True)
-                leg_con = plot_con.addLegend()
-                leg_con.setBrush((50, 50, 100, 200))
-                plot_con.addItem(self.ins_con)
+                self.plot_con = plot_con = add_axes(
+                    self.monitor, "Constraints", 'Evaluation History (C)',
+                    self.inspector_constraint, row=1, col=0
+                )
                 plot_con.setXLink(self.plot_obj)
 
-            for i, con_name in enumerate(self.con_names):
-                color = self.colors[i % len(self.colors)]
-                symbol = self.symbols[i % len(self.colors)]
-                _curve = self.plot_con.plot(pen=pg.mkPen(color, width=3),
-                                            # symbol=symbol,
-                                            name=con_name)
-                self.curves_con.append(_curve)
+            # Configure objective plots
+            self.curves_constraint = self._configure_plot(
+                self.plot_con, self.inspector_constraint, constraint_names
+            )
+
         else:
             try:
                 self.monitor.removeItem(self.plot_con)
-                self.plot_con.removeItem(self.ins_con)
+                self.plot_con.removeItem(self.inspector_constraint)
                 del self.plot_con
             except:
                 pass
 
-        if self.sta_names:
+        # Configure state plots
+        if sta_names:
             try:
                 self.plot_sta
             except:
-                self.plot_sta = plot_sta = self.monitor.addPlot(
-                    row=2, col=0, title='Evaluation History (S)')
-                plot_sta.setLabel('left', 'states')
-                plot_sta.setLabel('bottom', 'iterations')
-                plot_sta.showGrid(x=True, y=True)
-                leg_sta = plot_sta.addLegend()
-                leg_sta.setBrush((50, 50, 100, 200))
-                plot_sta.addItem(self.ins_sta)
+                self.plot_sta = plot_sta = add_axes(
+                    self.monitor, "Constants", 'Evaluation History (S)',
+                    self.inspector_state, row=1, col=0
+                )
                 plot_sta.setXLink(self.plot_obj)
 
-            for i, sta_name in enumerate(self.sta_names):
+            self.curves_sta = []
+            for i, sta_name in enumerate(sta_names):
                 color = self.colors[i % len(self.colors)]
                 symbol = self.symbols[i % len(self.colors)]
                 _curve = self.plot_sta.plot(pen=pg.mkPen(color, width=3),
-                                            # symbol=symbol,
                                             name=sta_name)
                 self.curves_sta.append(_curve)
         else:
             try:
                 self.monitor.removeItem(self.plot_sta)
-                self.plot_sta.removeItem(self.ins_sta)
+                self.plot_sta.removeItem(self.inspector_state)
                 del self.plot_sta
             except:
                 pass
 
         # Reset inspectors
-        self.ins_obj.setValue(0)
-        self.ins_var.setValue(0)
-        self.ins_con.setValue(0)
-        self.ins_sta.setValue(0)
+        self.inspector_objective.setValue(0)
+        self.inspector_variable.setValue(0)
+        self.inspector_constraint.setValue(0)
+        self.inspector_state.setValue(0)
 
         # Switch run button state
-        if self.routine:
-            self.btn_stop.setDisabled(False)
-        else:
-            self.btn_stop.setDisabled(True)
+        self.btn_stop.setDisabled(False)
 
         self.eval_count = 0  # reset the evaluation count
         self.enable_auto_range()
 
-        # Fill in data
-        self.data = data
-        self.vars = []
-        self.objs = []
-        self.cons = []
-        self.stas = []
-        self.ts = []
-        if self.routine_runner and self.routine_runner.run_filename == run_filename:
+        # Reset button should only be available if it's the current run
+        if self.routine_runner and \
+                self.routine_runner.run_filename == run_filename:
             self.btn_reset.setDisabled(False)
-            self.btn_set.setDisabled(False)
         else:
             self.btn_reset.setDisabled(True)
-            self.btn_set.setDisabled(True)
-        if data is None:
+
+        if routine.data is None:
             self.btn_del.setDisabled(True)
             self.btn_log.setDisabled(True)
             self.btn_opt.setDisabled(True)
-            return
+            self.btn_set.setDisabled(True)
 
-        for var in self.var_names:
-            self.vars.append(data[var])
-        self.vars = np.array(self.vars).T.tolist()
-        for obj in self.obj_names:
-            self.objs.append(data[obj])
-        self.objs = np.array(self.objs).T.tolist()
-        for con in self.con_names:
-            self.cons.append(data[con])
-        self.cons = np.array(self.cons).T.tolist()
-        for sta in self.sta_names:
-            self.stas.append(data[sta])
-        self.stas = np.array(self.stas).T.tolist()
-        self.ts = data['timestamp_raw']
+            return
 
         self.update_curves()
 
-        self.calc_optimals()
         self.btn_del.setDisabled(False)
         self.btn_log.setDisabled(False)
         self.btn_opt.setDisabled(False)
+        self.btn_set.setDisabled(False)
+
+    def _configure_plot(self, plot_object, inspector, names):
+        plot_object.clear()
+        plot_object.addItem(inspector)
+        curves = {}
+        for i, name in enumerate(names):
+            color = self.colors[i % len(self.colors)]
+            # symbol = self.symbols[i % len(self.colors)]
+            _curve = plot_object.plot(pen=pg.mkPen(color, width=3), name=name)
+            curves[name] = _curve
+
+        return curves
 
     def init_routine_runner(self):
         self.reset_routine_runner()
 
         self.routine_runner = routine_runner = BadgerRoutineRunner(
-            self.routine, False)
+            self.routine, False
+        )
         routine_runner.signals.env_ready.connect(self.env_ready)
         routine_runner.signals.finished.connect(self.routine_finished)
         routine_runner.signals.progress.connect(self.update)
@@ -584,20 +564,11 @@ def reset_routine_runner(self):
             self.sig_stop.disconnect()
             self.routine_runner = None
 
-    def calc_optimals(self):
-        rules = [d[next(iter(d))] for d in self.routine['config']['objectives']]
-        self.pf = pf = ParetoFront(rules)
-
-        for i, v in enumerate(self.vars):
-            o = self.objs[i]
-            idx = [i,] + v
-            pf.is_dominated((idx, o))
-
-    def start(self, use_tc=False):
+    def start(self, use_termination_condition=False):
         self.sig_new_run.emit()
         self.init_plots(self.routine)
         self.init_routine_runner()
-        if use_tc:
+        if use_termination_condition:
             self.routine_runner.set_termination_condition(self.termination_condition)
         self.running = True  # if a routine runner is working
         self.thread_pool.start(self.routine_runner)
@@ -611,114 +582,127 @@ def start(self, use_tc=False):
         # self.btn_stop.setToolTip('')
 
         self.btn_ctrl.setDisabled(False)
+        self.btn_set.setDisabled(True)
         self.sig_lock.emit(True)
 
     def save_termination_condition(self, tc):
         self.termination_condition = tc
 
-    def is_critical(self, cons):
-        if not self.con_names:
-            return False, None
-
-        constraints = self.routine['config']['constraints']
-        for i, con_dict in enumerate(constraints):
-            name = self.con_names[i]
-            if len(con_dict[name]) != 3:
-                continue
-
-            value = cons[i]
-            relation, thres = con_dict[name][:2]
-            if relation == 'GREATER_THAN':
-                if value <= thres:
-                    return True, f'{name} (current value: {value:.4f}) is less than {thres}!'
-            elif relation == 'LESS_THAN':
-                if value >= thres:
-                    return True, f'{name} (current value: {value:.4f}) is greater than {thres}!'
-            else:
-                if value != thres:
-                    return True, f'{name} (current value: {value:.4f}) is not equal to {thres}!'
-
-        return False, None
-
     def enable_auto_range(self):
         # Enable autorange
         self.plot_obj.enableAutoRange()
         self.plot_var.enableAutoRange()
-        if self.con_names:
+        if self.vocs.constraint_names:
             self.plot_con.enableAutoRange()
-        if self.sta_names:
-            self.plot_sta.enableAutoRange()
-
-    def update_curves(self):
-        type_x = self.plot_x_axis
-        type_y = self.x_plot_y_axis
-        relative = self.x_plot_relative
 
-        if type_y:
-            _vars = norm(np.array(self.vars),
-                         self.vranges[:, 0], self.vranges[:, 1])
-        else:
-            _vars = np.array(self.vars)
+        # if self.sta_names:
+        #    self.plot_sta.enableAutoRange()
 
-        if relative:
-            _vars = _vars - _vars[0]
+    def open_extensions_palette(self):
+        self.extensions_palette.show()
 
-        if type_x:
-            ts = np.array(self.ts)
-            ts -= ts[0]
+    def extension_window_closed(self, child_window):
+        self.active_extensions.remove(child_window)
+        self.extensions_palette.update_palette()
 
-        for i in range(len(self.var_names)):
-            if type_x:
-                self.curves_var[i].setData(ts, _vars[:, i])
-            else:
-                self.curves_var[i].setData(_vars[:, i])
+    def extract_timestamp(self, data=None):
+        if data is None:
+            data = self.routine.sorted_data
 
-        for i in range(len(self.obj_names)):
-            if type_x:
-                self.curves_obj[i].setData(ts, np.array(self.objs)[:, i])
-            else:
-                self.curves_obj[i].setData(np.array(self.objs)[:, i])
-
-        if self.con_names:
-            for i in range(len(self.con_names)):
-                if type_x:
-                    self.curves_con[i].setData(ts, np.array(self.cons)[:, i])
-                else:
-                    self.curves_con[i].setData(np.array(self.cons)[:, i])
-
-        if self.sta_names:
-            for i in range(len(self.sta_names)):
-                if type_x:
-                    self.curves_sta[i].setData(ts, np.array(self.stas)[:, i])
-                else:
-                    self.curves_sta[i].setData(np.array(self.stas)[:, i])
-
-    def update(self, vars, objs, cons, stas, ts):
-        self.vars.append(vars)
-        self.objs.append(objs)
-        self.cons.append(cons)
-        self.stas.append(stas)
-        self.ts.append(ts)
+        return data["timestamp"].to_numpy(copy=True)
 
+    def update(self):
+        # update plots in main window as well as any active extensions and the
+        # extensions palette
         self.update_curves()
+        self.update_analysis_extensions()
+        self.extensions_palette.update_palette()
 
         # Quick-n-dirty fix to the auto range issue
         self.eval_count += 1
         if self.eval_count < 5:
             self.enable_auto_range()
 
-        self.sig_progress.emit(vars, objs, cons, stas)
+        self.sig_progress.emit(self.routine.data.tail(1))
 
         # Check critical condition
-        critical, msg = self.is_critical(cons)
-        if not critical:
+        self.check_critical()
+
+    def update_curves(self):
+        use_time_axis = self.plot_x_axis == 1
+        normalize_inputs = self.x_plot_y_axis == 1
+
+        data_copy = self.routine.sorted_data
+
+        # Get timestamps
+        if use_time_axis:
+            ts = self.extract_timestamp(data_copy)
+            ts -= ts[0]
+        else:
+            ts = None
+
+        variable_names = self.vocs.variable_names
+
+        # if normalize x, normalize using vocs
+        if normalize_inputs:
+            input_data = self.vocs.normalize_inputs(data_copy)
+        else:
+            input_data = data_copy[variable_names]
+
+        # if plot relative, subtract the first value from the dict
+        if self.x_plot_relative:
+            input_data[variable_names] = input_data[variable_names] - \
+                                        input_data[variable_names].iloc[0]
+
+        set_data(variable_names, self.curves_variable, input_data, ts)
+        set_data(self.vocs.objective_names, self.curves_objective, data_copy, ts)
+        set_data(self.vocs.constraint_names, self.curves_constraint, data_copy, ts)
+
+        # TODO: add tracking of observables
+
+    def check_critical(self):
+        """
+        Check if a critical constraint has been violated in the last data point,
+        and take appropriate actions if so.
+
+        If there are no critical constraints, the function will return without taking
+        any action. If a critical constraint has been violated, it will pause the
+        run, open a dialog to inform the user about the violation, and provide
+        options to terminate or resume the run.
+
+        Returns
+        -------
+        None
+
+        Notes
+        -----
+
+        The critical constraints are determined by the
+        `self.routine.critical_constraint_names` attribute. If no critical
+        constraints are defined, this function will have no effect.
+
+        The function emits signals `self.sig_pause` and `self.sig_stop` to handle the
+        pause and stop actions.
+
+        """
+
+        # if there are no critical constraints then skip
+        if len(self.routine.critical_constraint_names) == 0:
             return
+        else:
+            feas = self.vocs.feasibility_data(self.routine.data.iloc[-1])
+            violated_critical = ~feas[self.routine.critical_constraint_names].any()
+
+            if not violated_critical:
+                return
 
+        # if code reaches this point there is a critical constraint violated
         self.sig_pause.emit(True)
         self.btn_ctrl.setIcon(self.icon_play)
         self.btn_ctrl.setToolTip('Resume')
         self.btn_ctrl._status = 'play'
 
+        msg = str(feas)
         reply = QMessageBox.warning(self,
                                     'Run Paused',
                                     f'Critical constraint was violated: {msg}\nTerminate the run?',
@@ -726,8 +710,14 @@ def update(self, vars, objs, cons, stas, ts):
         if reply == QMessageBox.Yes:
             self.sig_stop.emit()
 
+    def update_analysis_extensions(self):
+        for ele in self.active_extensions:
+            try:
+                ele.update_window(self.routine)
+            except ValueError:
+                traceback.print_exc()
+
     def env_ready(self, init_vars):
-        self.env = self.routine_runner.env
         self.init_vars = init_vars
 
         self.btn_log.setDisabled(False)
@@ -757,24 +747,38 @@ def routine_finished(self):
         self.sig_lock.emit(False)
 
         try:
-            run = archive_run(self.routine_runner.routine, self.routine_runner.data,
-                self.routine_runner.states)
+            # TODO: fill in the states
+            run = archive_run(self.routine, states=None)
             self.routine_runner.run_filename = run['filename']
+            env = self.routine.environment
+            path = run['path']
+            filename = run['filename'][:-4] + 'pickle'
             try:
-                path = run['path']
-                filename = run['filename'][:-4] + 'pickle'
-                self.env.interface.stop_recording(os.path.join(path, filename))
-            except:
+                env.interface.stop_recording(os.path.join(path, filename))
+            except AttributeError:  # recording was not enabled
                 pass
-            self.sig_run_name.emit(run['filename'])
 
-            QMessageBox.information(
-                self, 'Success!', f'Archive succeeded: Run data archived to {BADGER_ARCHIVE_ROOT}')
+            self.sig_run_name.emit(run['filename'])
+            if not self.testing:
+                QMessageBox.information(
+                    self, 'Success!',
+                    f'Archive succeeded: Run data archived to {BADGER_ARCHIVE_ROOT}')
 
         except Exception as e:
             self.sig_run_name.emit(None)
-
-            QMessageBox.critical(self, 'Archive failed!', f'Archive failed: {str(e)}')
+            if not self.testing:
+                QMessageBox.critical(self, 'Archive failed!',
+                                     f'Archive failed: {str(e)}')
+        finally:
+            for action in self.post_run_actions:
+                action()
+
+    def destroy_unused_env(self):
+        if not self.running:
+            try:
+                del self.routine.environment
+            except AttributeError:  # env already destroyed
+                pass
 
     def on_error(self, error):
         QMessageBox.critical(self, 'Error!', str(error))
@@ -787,7 +791,7 @@ def on_info(self, msg):
     def logbook(self):
         try:
             if self.routine_runner:
-                routine = self.routine_runner.routine
+                routine = self.routine_runner.name
                 data = self.routine_runner.data.to_dict('list')
             else:
                 routine = self.routine
@@ -812,30 +816,30 @@ def ctrl_routine(self):
             self.btn_ctrl._status = 'pause'
 
     def ins_obj_dragged(self, ins_obj):
-        self.ins_var.setValue(ins_obj.value())
-        if self.con_names:
-            self.ins_con.setValue(ins_obj.value())
-        if self.sta_names:
-            self.ins_sta.setValue(ins_obj.value())
+        self.inspector_variable.setValue(ins_obj.value())
+        if self.vocs.constraint_names:
+            self.inspector_constraint.setValue(ins_obj.value())
+        # if self.sta_names:
+        #    self.inspector_state.setValue(ins_obj.value())
 
     def ins_con_dragged(self, ins_con):
-        self.ins_var.setValue(ins_con.value())
-        self.ins_obj.setValue(ins_con.value())
-        if self.sta_names:
-            self.ins_sta.setValue(ins_con.value())
+        self.inspector_variable.setValue(ins_con.value())
+        self.inspector_objective.setValue(ins_con.value())
+        # if self.sta_names:
+        #    self.inspector_state.setValue(ins_con.value())
 
     def ins_sta_dragged(self, ins_sta):
-        self.ins_var.setValue(ins_sta.value())
-        self.ins_obj.setValue(ins_sta.value())
-        if self.con_names:
-            self.ins_con.setValue(ins_sta.value())
+        self.inspector_variable.setValue(ins_sta.value())
+        self.inspector_objective.setValue(ins_sta.value())
+        # if self.vocs.constraint_names:
+        #    self.inspector_constraint.setValue(ins_sta.value())
 
     def ins_var_dragged(self, ins_var):
-        self.ins_obj.setValue(ins_var.value())
-        if self.con_names:
-            self.ins_con.setValue(ins_var.value())
-        if self.sta_names:
-            self.ins_sta.setValue(ins_var.value())
+        self.inspector_objective.setValue(ins_var.value())
+        if self.vocs.constraint_names:
+            self.inspector_constraint.setValue(ins_var.value())
+        # if self.sta_names:
+        #    self.inspector_state.setValue(ins_var.value())
 
     def ins_drag_done(self, ins):
         self.sync_ins(ins.value())
@@ -844,19 +848,20 @@ def sync_ins(self, pos):
         if self.plot_x_axis:  # x-axis is time
             value, idx = self.closest_ts(pos)
         else:
-            value = idx = np.clip(np.round(pos), 0, len(self.ts) - 1)
-        self.ins_obj.setValue(value)
-        if self.con_names:
-            self.ins_con.setValue(value)
-        if self.sta_names:
-            self.ins_sta.setValue(value)
-        self.ins_var.setValue(value)
+            ts = self.extract_timestamp()
+            value = idx = np.clip(np.round(pos), 0, len(ts) - 1)
+        self.inspector_objective.setValue(value)
+        if self.vocs.constraint_names:
+            self.inspector_constraint.setValue(value)
+        # if self.sta_names:
+        #    self.inspector_state.setValue(value)
+        self.inspector_variable.setValue(value)
 
         self.sig_inspect.emit(idx)
 
     def closest_ts(self, t):
-        # Get the closest timestamp in self.ts regarding t
-        ts = np.array(self.ts)
+        # Get the closest timestamp in data regarding t
+        ts = self.extract_timestamp()
         ts -= ts[0]
         idx = np.argmin(np.abs(ts - t))
 
@@ -865,57 +870,62 @@ def closest_ts(self, t):
     def reset_env(self):
         reply = QMessageBox.question(self,
                                      'Reset Environment',
-                                     f'Are you sure you want to reset the env vars back to {self.init_vars}?',
+                                     f'Are you sure you want to reset the env vars '
+                                     f'back to {self.init_vars}?',
                                      QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
         if reply != QMessageBox.Yes:
             return
 
-        _var_dict = self.env._get_variables(self.var_names)
-        current_vars = [_var_dict[v] for v in self.var_names]
-        self.env._set_variables(dict(zip(self.var_names, self.init_vars)))
-        _var_dict = self.env._get_variables(self.var_names)
-        after_vars = [_var_dict[v] for v in self.var_names]
-        QMessageBox.information(
-            self, 'Reset Environment', f'Env vars {current_vars} -> {after_vars}')
-
-    def jump_to_optimal(self):
-        try:
-            in_a_run = not self.routine_runner.is_killed
-        except:
-            in_a_run = False
+        # TODO: Should just get vars from env! Current values are not always
+        # ones in the latest solution
+        # current_vars = self.routine.data.iloc[-1].to_dict(orient="records")
+        current_vars = self.routine.sorted_data[
+            self.vocs.variable_names].iloc[-1].to_numpy().tolist()
 
-        if in_a_run:
-            pf = self.routine_runner.pf
-        else:
-            pf = self.pf
+        # evaluate the initial variables -- do not store the result
+        # self.routine.evaluate(self.init_vars)
+        self.routine.environment._set_variables(
+            dict(zip(self.vocs.variable_names, self.init_vars)))
 
-        if pf is None:
-            return
+        QMessageBox.information(self, 'Reset Environment',
+                                f'Env vars {current_vars} -> {self.init_vars}')
 
-        idx = pf.pareto_set[0][0]
-        self.jump_to_solution(int(idx))
-        self.sig_inspect.emit(idx)
+    def jump_to_optimal(self):
+        try:
+            best_idx, _ = self.routine.vocs.select_best(
+                self.routine.sorted_data, n=1)
+            best_idx = int(best_idx[0])
+
+            self.jump_to_solution(best_idx)
+            self.sig_inspect.emit(best_idx)
+        except NotImplementedError:
+            QMessageBox.warning(
+                self, 'Jump to optimum',
+                'Jump to optimum is not supported for '
+                'multi-objective optimization yet')
 
     def jump_to_solution(self, idx):
         if self.plot_x_axis:  # x-axis is time
-            value = self.ts[idx] - self.ts[0]
+            ts = self.extract_timestamp()
+            value = ts[idx] - ts[0]
         else:
             value = idx
 
-        self.ins_obj.setValue(value)
-        if self.con_names:
-            self.ins_con.setValue(value)
-        if self.sta_names:
-            self.ins_sta.setValue(value)
-        self.ins_var.setValue(value)
+        self.inspector_objective.setValue(value)
+        if self.vocs.constraint_names:
+            self.inspector_constraint.setValue(value)
+        # if self.sta_names:
+        #    self.inspector_state.setValue(value)
+        self.inspector_variable.setValue(value)
 
     def set_vars(self):
-        df = self.routine_runner.data
+        df = self.routine.sorted_data
         if self.plot_x_axis:  # x-axis is time
-            pos, idx = self.closest_ts(self.ins_obj.value())
+            pos, idx = self.closest_ts(self.inspector_objective.value())
         else:
-            pos = idx = int(self.ins_obj.value())
-        solution = df.loc[idx, self.var_names].to_numpy()
+            pos = idx = int(self.inspector_objective.value())
+        variable_names = self.vocs.variable_names
+        solution = df[variable_names].to_numpy()[idx]
 
         reply = QMessageBox.question(self,
                                      'Apply Solution',
@@ -924,7 +934,8 @@ def set_vars(self):
         if reply != QMessageBox.Yes:
             return
 
-        self.env._set_variables(dict(zip(self.var_names, solution)))
+        self.routine.environment._set_variables(
+            dict(zip(variable_names, solution)))
         # center around the inspector
         x_range = self.plot_var.getViewBox().viewRange()[0]
         delta = (x_range[1] - x_range[0]) / 2
@@ -939,29 +950,30 @@ def select_x_axis(self, i):
         if i:
             self.plot_var.setLabel('bottom', 'time (s)')
             self.plot_obj.setLabel('bottom', 'time (s)')
-            if self.con_names:
+            if self.vocs.constraint_names:
                 self.plot_con.setLabel('bottom', 'time (s)')
-            if self.sta_names:
-                self.plot_sta.setLabel('bottom', 'time (s)')
+            # if self.sta_names:
+            #    self.plot_sta.setLabel('bottom', 'time (s)')
         else:
             self.plot_var.setLabel('bottom', 'iterations')
             self.plot_obj.setLabel('bottom', 'iterations')
-            if self.con_names:
+            if self.vocs.constraint_names:
                 self.plot_con.setLabel('bottom', 'iterations')
-            if self.sta_names:
-                self.plot_sta.setLabel('bottom', 'iterations')
+            # if self.sta_names:
+            #     self.plot_sta.setLabel('bottom', 'iterations')
 
         # Update inspector line position
         if i:
-            value = self.ts[int(self.ins_obj.value())] - self.ts[0]
+            ts = self.extract_timestamp()
+            value = ts[int(self.inspector_objective.value())] - ts[0]
         else:
-            _, value = self.closest_ts(self.ins_obj.value())
-        self.ins_obj.setValue(value)
-        if self.con_names:
-            self.ins_con.setValue(value)
-        if self.sta_names:
-            self.ins_sta.setValue(value)
-        self.ins_var.setValue(value)
+            _, value = self.closest_ts(self.inspector_objective.value())
+        self.inspector_objective.setValue(value)
+        if self.vocs.constraint_names:
+            self.inspector_constraint.setValue(value)
+        # if self.sta_names:
+        #    self.inspector_state.setValue(value)
+        self.inspector_variable.setValue(value)
 
         self.update_curves()
         self.enable_auto_range()
@@ -977,18 +989,18 @@ def toggle_x_plot_y_axis_relative(self):
     def on_mouse_click(self, event):
         # https://stackoverflow.com/a/64081483
         coor_obj = self.plot_obj.vb.mapSceneToView(event._scenePos)
-        if self.con_names:
+        if self.vocs.constraint_names:
             coor_con = self.plot_con.vb.mapSceneToView(event._scenePos)
-        if self.sta_names:
-            coor_sta = self.plot_sta.vb.mapSceneToView(event._scenePos)
+        # if self.sta_names:
+        #    coor_sta = self.plot_sta.vb.mapSceneToView(event._scenePos)
         coor_var = self.plot_var.vb.mapSceneToView(event._scenePos)
 
         flag = self.plot_obj.viewRect().contains(coor_obj) or \
-            self.plot_var.viewRect().contains(coor_var)
-        if self.con_names:
+               self.plot_var.viewRect().contains(coor_var)
+        if self.vocs.constraint_names:
             flag = flag or self.plot_con.viewRect().contains(coor_con)
-        if self.sta_names:
-            flag = flag or self.plot_sta.viewRect().contains(coor_sta)
+        # if self.sta_names:
+        #    flag = flag or self.plot_sta.viewRect().contains(coor_sta)
 
         if flag:
             self.sync_ins(coor_obj.x())
@@ -1015,22 +1027,45 @@ def set_run_until_action(self):
                 self, self.start,
                 self.save_termination_condition, self.termination_condition,
             )
-            dlg.exec()
+            self.tc_dialog = dlg
+            try:
+                dlg.exec()
+            finally:
+                self.tc_dialog = None
         else:
             self.btn_stop.setDisabled(True)
             self.sig_stop.emit()
 
-    # def closeEvent(self, event):
-    #     if not self.running:
-    #         return
+    def register_post_run_action(self, action):
+        self.post_run_actions.append(action)
+
+
+def add_axes(monitor, ylabel, title, cursor_line, **kwargs):
+    plot_obj = monitor.addPlot(title=title, **kwargs)
+    plot_obj.setLabel('left', ylabel)
+    plot_obj.setLabel('bottom', 'iterations')
+    plot_obj.showGrid(x=True, y=True)
+    leg_obj = plot_obj.addLegend()
+    leg_obj.setBrush((50, 50, 100, 200))
+
+    plot_obj.addItem(cursor_line)
+
+    return plot_obj
 
-    #     reply = QMessageBox.question(self,
-    #                                  'Window Close',
-    #                                  'Closing this window will terminate the run, and the run data would NOT be archived! Proceed?',
-    #                                  QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
 
-    #     if reply == QMessageBox.Yes:
-    #         self.sig_stop.emit()
-    #         event.accept()
-    #     else:
-    #         event.ignore()
+def create_cursor_line():
+    return pg.InfiniteLine(movable=True, angle=90, label=None,
+                           labelOpts={
+                               'position': 0.1,
+                               'color': (200, 200, 100),
+                               'fill': (200, 200, 200, 50),
+                               'movable': True})
+
+
+def set_data(names: list[str], curves: dict, data: pd.DataFrame, ts=None):
+
+    for name in names:
+        if ts is not None:
+            curves[name].setData(ts, data[name].to_numpy(dtype=np.double))
+        else:
+            curves[name].setData(data[name].to_numpy(dtype=np.double))
diff --git a/src/badger/gui/default/components/var_table.py b/src/badger/gui/default/components/var_table.py
index 7f9dc914..d53cc59f 100644
--- a/src/badger/gui/default/components/var_table.py
+++ b/src/badger/gui/default/components/var_table.py
@@ -79,10 +79,9 @@ def update_bounds(self):
             sb_upper = self.cellWidget(i, 3)
             self.bounds[name] = [sb_lower.value(), sb_upper.value()]
 
-    def set_bounds(self, variables):
-        for var in variables:
-            name = next(iter(var))
-            self.bounds[name] = var[name]
+    def set_bounds(self, variables: dict):
+        for name in variables:
+            self.bounds[name] = variables[name]
 
         self.update_variables(self.variables, 2)
 
@@ -202,13 +201,11 @@ def add_variable(self, name, lb, ub):
         self.all_variables.append(var)
         self.bounds[name] = [lb, ub]
 
-    def export_variables(self):
-        variables_exported = []
+    def export_variables(self) -> dict:
+        variables_exported = {}
         for var in self.all_variables:
             name = next(iter(var))
-            _var = {}
-            _var[name] = self.bounds[name]
             if self.is_checked(name):
-                variables_exported.append(_var)
+                variables_exported[name] = self.bounds[name]
 
         return variables_exported
diff --git a/src/badger/gui/default/images/extension-alt.png b/src/badger/gui/default/images/extension-alt.png
new file mode 100644
index 00000000..963d9148
Binary files /dev/null and b/src/badger/gui/default/images/extension-alt.png differ
diff --git a/src/badger/gui/default/images/extension.png b/src/badger/gui/default/images/extension.png
new file mode 100644
index 00000000..86ee64f0
Binary files /dev/null and b/src/badger/gui/default/images/extension.png differ
diff --git a/src/badger/gui/default/images/next.png b/src/badger/gui/default/images/next.png
new file mode 100644
index 00000000..e6e0a41c
Binary files /dev/null and b/src/badger/gui/default/images/next.png differ
diff --git a/src/badger/gui/default/images/previous.png b/src/badger/gui/default/images/previous.png
new file mode 100644
index 00000000..fc5db69b
Binary files /dev/null and b/src/badger/gui/default/images/previous.png differ
diff --git a/src/badger/gui/default/pages/home_page.py b/src/badger/gui/default/pages/home_page.py
index 93e6c878..4ca67a9b 100644
--- a/src/badger/gui/default/pages/home_page.py
+++ b/src/badger/gui/default/pages/home_page.py
@@ -1,5 +1,7 @@
 import os
 from importlib import resources
+from pandas import DataFrame
+
 from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QMessageBox
 from PyQt5.QtWidgets import QPushButton, QSplitter, QTabWidget, QShortcut
 from PyQt5.QtWidgets import QListWidget, QListWidgetItem, QLabel, QFileDialog
@@ -13,6 +15,7 @@
 from ..components.routine_editor import BadgerRoutineEditor
 from ..components.status_bar import BadgerStatusBar
 from ..components.filter_cbox import BadgerFilterBox
+from ..utils import create_button
 from ....db import list_routine, load_routine, remove_routine, get_runs_by_routine, get_runs
 from ....db import import_routines, export_routines
 from ....archive import load_run, delete_run
@@ -111,7 +114,7 @@ def init_ui(self):
         routine_list.setSpacing(1)
         routine_list.setViewportMargins(0, 0, 17, 0)  # leave space for scrollbar
         self.refresh_routine_list()
-        self.prev_routine = None  # last selected routine
+        self.prev_routine_item = None  # last selected routine
         vbox_routine.addWidget(routine_list)
 
         # Action bar
@@ -148,16 +151,16 @@ def init_ui(self):
 
         label_nav = QLabel('History Run')
         self.cb_history = cb_history = HistoryNavigator()
-        self.btn_prev = btn_prev = QPushButton('<')
-        self.btn_next = btn_next = QPushButton('>')
-        btn_prev.setFixedWidth(32)
-        btn_next.setFixedWidth(32)
+        self.btn_prev = btn_prev = create_button(
+            "next.png", "Go to the next run", size=(24, 24))
+        self.btn_next = btn_next = create_button(
+            "previous.png", "Go to the previous run", size=(24, 24))
         btn_prev.setDisabled(True)
         btn_next.setDisabled(True)
         hbox_nav.addWidget(label_nav)
         hbox_nav.addWidget(cb_history, 1)
-        hbox_nav.addWidget(btn_prev)
         hbox_nav.addWidget(btn_next)
+        hbox_nav.addWidget(btn_prev)
 
         self.tabs = tabs = QTabWidget()
         vbox_view.addWidget(tabs)
@@ -229,6 +232,7 @@ def config_logic(self):
         self.routine_editor.sig_saved.connect(self.routine_saved)
         self.routine_editor.sig_canceled.connect(self.done_create_routine)
         self.routine_editor.sig_deleted.connect(self.routine_deleted)
+        self.routine_editor.routine_page.sig_updated.connect(self.routine_description_updated)
 
         # Assign shortcuts
         self.shortcut_go_search = QShortcut(QKeySequence('Ctrl+L'), self)
@@ -254,15 +258,16 @@ def create_new_routine(self):
         self.routine_editor.switch_mode(self.mode)
         self.toggle_lock(True, 0)
 
-    def select_routine(self, item):
-        if self.prev_routine:
+    def select_routine(self, routine_item: QListWidgetItem):
+        if self.prev_routine_item:
             try:
-                self.routine_list.itemWidget(self.prev_routine).deactivate()
-            except:
+                self.routine_list.itemWidget(self.prev_routine_item).deactivate()
+            except ValueError:
                 pass
 
-            if self.prev_routine.routine == item.routine:  # click a routine again to deselect
-                self.prev_routine = None
+            if self.prev_routine_item.routine_name == routine_item.routine_name:  #
+                # click a routine again to deselect
+                self.prev_routine_item = None
                 self.current_routine = None
                 self.load_all_runs()
                 if not self.cb_history.count():
@@ -270,13 +275,13 @@ def select_routine(self, item):
                 self.sig_routine_activated.emit(False)
                 return
 
-        self.prev_routine = item  # note that prev_routine is an item!
+        self.prev_routine_item = routine_item  # note that prev_routine is an item!
         self.sig_routine_activated.emit(True)
 
-        routine, timestamp = load_routine(item.routine)
+        routine, timestamp = load_routine(routine_item.routine_name)
         self.current_routine = routine
         self.routine_editor.set_routine(routine)
-        runs = get_runs_by_routine(routine['name'])
+        runs = get_runs_by_routine(routine.name)
         self.cb_history.updateItems(runs)
         if not self.cb_history.count():
             self.go_run(-1)  # sometimes we need to trigger this manually
@@ -284,25 +289,29 @@ def select_routine(self, item):
         if not runs:  # auto plot will not be triggered
             self.run_monitor.init_plots(routine)
 
-        self.routine_list.itemWidget(item).activate()
+        self.routine_list.itemWidget(routine_item).activate()
 
-    def build_routine_list(self, routines, timestamps):
+    def build_routine_list(self,
+                           routines: list[str],
+                           timestamps: list[str],
+                           descriptions: list[str]):
         try:
-            selected_routine = self.prev_routine.routine
-        except:
+            selected_routine = self.prev_routine_item.routine_name
+        except Exception:
             selected_routine = None
         self.routine_list.clear()
         for i, routine in enumerate(routines):
-            _item = BadgerRoutineItem(routine, timestamps[i])
+            _item = BadgerRoutineItem(routine, timestamps[i],
+                                      descriptions[i], self)
             _item.sig_del.connect(self.delete_routine)
             item = QListWidgetItem(self.routine_list)
-            item.routine = routine  # dirty trick
+            item.routine_name = routine  # dirty trick
             item.setSizeHint(_item.sizeHint())
             self.routine_list.addItem(item)
             self.routine_list.setItemWidget(item, _item)
             if routine == selected_routine:
                 _item.activate()
-                self.prev_routine = item
+                self.prev_routine_item = item
 
     def get_current_routines(self):
         keyword = self.sbar.text()
@@ -310,19 +319,22 @@ def get_current_routines(self):
         tag_reg = self.filter_box.cb_reg.currentText()
         tag_gain = self.filter_box.cb_gain.currentText()
         tags = {}
-        if tag_obj: tags['objective'] = tag_obj
-        if tag_reg: tags['region'] = tag_reg
-        if tag_gain: tags['gain'] = tag_gain
-        routines, timestamps = list_routine(keyword, tags)
+        if tag_obj:
+            tags['objective'] = tag_obj
+        if tag_reg:
+            tags['region'] = tag_reg
+        if tag_gain:
+            tags['gain'] = tag_gain
+        routines, timestamps, descriptions = list_routine(keyword, tags)
 
-        return routines, timestamps
+        return routines, timestamps, descriptions
 
     def refresh_routine_list(self):
-        routines, timestamps = self.get_current_routines()
+        routines, timestamps, descriptions = self.get_current_routines()
 
-        self.build_routine_list(routines, timestamps)
+        self.build_routine_list(routines, timestamps, descriptions)
 
-    def go_run(self, i):
+    def go_run(self, i: int):
         if self.cb_history.itemText(0) == 'Optimization in progress...':
             return
         # if self.cb_history.currentText() == 'Optimization in progress...':
@@ -338,19 +350,21 @@ def go_run(self, i):
                 self.routine_editor.clear()
                 self.status_bar.set_summary('no active routine')
             else:
-                self.status_bar.set_summary(f'current routine: {self.current_routine["name"]}')
+                self.status_bar.set_summary(f'current routine: {self.current_routine.name}')
             return
 
         run_filename = self.cb_history.currentText()
         try:
-            run = load_run(run_filename)
-        except:
+            _routine = load_run(run_filename)
+            routine, _ = load_routine(_routine.name)  # get the initial routine
+            routine.data = _routine.data
+        except (IndexError, AttributeError):
             return
-        self.current_routine = run['routine']  # update the current routine
-        update_table(self.run_table, run['data'])
-        self.run_monitor.init_plots(run['routine'], run['data'], run_filename)
-        self.routine_editor.set_routine(run['routine'])
-        self.status_bar.set_summary(f'current routine: {self.current_routine["name"]}')
+        self.current_routine = routine  # update the current routine
+        update_table(self.run_table, routine.sorted_data)
+        self.run_monitor.init_plots(routine, run_filename)
+        self.routine_editor.set_routine(routine)
+        self.status_bar.set_summary(f'current routine: {self.current_routine.name}')
 
     def go_prev_run(self):
         self.cb_history.selectPreviousItem()
@@ -402,24 +416,37 @@ def new_run(self):
         reset_table(self.run_table, header)
 
     def run_name(self, name):
-        if self.prev_routine:
-            runs = get_runs_by_routine(self.current_routine['name'])
+        if self.prev_routine_item:
+            runs = get_runs_by_routine(self.current_routine.name)
         else:
             runs = get_runs()
         self.cb_history.updateItems(runs)
 
-    def progress(self, vars, objs, cons, stas):
+    def progress(self, solution: DataFrame):
+        vocs = self.current_routine.vocs
+        vars = list(solution[vocs.variable_names].to_numpy()[0])
+        objs = list(solution[vocs.objective_names].to_numpy()[0])
+        cons = list(solution[vocs.constraint_names].to_numpy()[0])
+        stas = list(solution[vocs.observable_names].to_numpy()[0])
         add_row(self.run_table, objs + cons + vars + stas)
 
     def delete_run(self):
         run_name = self.cb_history.currentText()
+
+        reply = QMessageBox.question(
+            self, 'Delete run',
+            f'Are you sure you want to delete run {run_name}?',
+            QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
+        if reply != QMessageBox.Yes:
+            return
+
         delete_run(run_name)
         # Reset current routine if no routine is selected
-        if not self.prev_routine:
+        if not self.prev_routine_item:
             self.current_routine = None
             runs = get_runs()
         else:
-            runs = get_runs_by_routine(self.current_routine['name'])
+            runs = get_runs_by_routine(self.current_routine.name)
         self.cb_history.updateItems(runs)
         if not self.cb_history.count():
             self.go_run(-1)  # sometimes we need to trigger this manually
@@ -449,9 +476,9 @@ def delete_routine(self, name):
         self.routine_deleted(name)
 
     def routine_deleted(self, name=None):
-        if self.prev_routine:
-            if self.prev_routine.routine == name:
-                self.prev_routine = None
+        if self.prev_routine_item:
+            if self.prev_routine_item.routine_name == name:
+                self.prev_routine_item = None
                 self.current_routine = None
                 self.load_all_runs()
                 if not self.cb_history.count():
@@ -460,6 +487,15 @@ def routine_deleted(self, name=None):
 
         self.refresh_routine_list()
 
+    def routine_description_updated(self, name, descr):
+        for i in range(self.routine_list.count()):
+            item = self.routine_list.item(i)
+            if item is not None:
+                routine_item = self.routine_list.itemWidget(item)
+                if routine_item.name == name:
+                    routine_item.update_description(descr)
+                    break
+
     def export_routines(self):
         options = QFileDialog.Options()
         options |= QFileDialog.DontUseNativeDialog
@@ -473,7 +509,7 @@ def export_routines(self):
             filename = filename + '.db'
 
         try:
-            routines, _ = self.get_current_routines()
+            routines, _, _ = self.get_current_routines()
             export_routines(filename, routines)
 
             QMessageBox.information(
diff --git a/src/badger/gui/default/utils.py b/src/badger/gui/default/utils.py
index 6fc9d8e2..15f245d1 100644
--- a/src/badger/gui/default/utils.py
+++ b/src/badger/gui/default/utils.py
@@ -1,8 +1,11 @@
-from PyQt5.QtWidgets import QWidget, QAbstractSpinBox
-from PyQt5.QtCore import Qt, QObject, QEvent
+from importlib import resources
+from PyQt5.QtWidgets import QWidget, QAbstractSpinBox, QPushButton
+from PyQt5.QtCore import Qt, QObject, QEvent, QSize
+from PyQt5.QtGui import QIcon
 
 
-def preventAnnoyingSpinboxScrollBehaviour(self, control: QAbstractSpinBox) -> None:
+def preventAnnoyingSpinboxScrollBehaviour(
+        self, control: QAbstractSpinBox) -> None:
     control.setFocusPolicy(Qt.StrongFocus)
     control.installEventFilter(self.MouseWheelWidgetAdjustmentGuard(control))
 
@@ -17,3 +20,23 @@ def eventFilter(self, o: QObject, e: QEvent) -> bool:
             e.ignore()
             return True
         return super().eventFilter(o, e)
+
+
+def create_button(icon_file, tooltip,
+                  stylesheet=None, size=(32, 32), icon_size=None):
+    icon_ref = resources.files(__package__) / f'./images/{icon_file}'
+    with resources.as_file(icon_ref) as icon_path:
+        icon = QIcon(str(icon_path))
+
+    btn = QPushButton()
+    if size:
+        btn.setFixedSize(*size)
+    btn.setIcon(icon)
+    btn.setToolTip(tooltip)
+    if icon_size:
+        btn.setIconSize(QSize(*icon_size))
+
+    if stylesheet is not None:
+        btn.setStyleSheet(stylesheet)
+
+    return btn
diff --git a/src/badger/gui/default/windows/docs_window.py b/src/badger/gui/default/windows/docs_window.py
index 147a3b7b..88e8aebe 100644
--- a/src/badger/gui/default/windows/docs_window.py
+++ b/src/badger/gui/default/windows/docs_window.py
@@ -1,12 +1,12 @@
 from PyQt5.QtWidgets import QTextEdit, QHBoxLayout, QVBoxLayout, QCheckBox, QWidget, QMainWindow
-from ....factory import get_algo_docs
+from ....factory import get_generator_docs
 
 
 class BadgerDocsWindow(QMainWindow):
-    def __init__(self, parent, algo):
+    def __init__(self, parent, generator):
         super().__init__(parent=parent)
 
-        self.algo = algo
+        self.generator = generator
         self.render_md = True
         self.docs = None
 
@@ -15,7 +15,7 @@ def __init__(self, parent, algo):
         self.load_docs()
 
     def init_ui(self):
-        self.setWindowTitle(f'Docs for algorithm {self.algo}')
+        self.setWindowTitle(f'Docs for generator {self.generator}')
         self.resize(640, 640)
 
         doc_panel = QWidget(self)
@@ -42,7 +42,7 @@ def config_logic(self):
 
     def load_docs(self):
         try:
-            self.docs = docs = get_algo_docs(self.algo)
+            self.docs = docs = get_generator_docs(self.generator)
         except Exception as e:
             self.docs = docs = str(e)
 
@@ -51,9 +51,9 @@ def load_docs(self):
         else:
             self.markdown_viewer.setText(docs)
 
-    def update_docs(self, algo):
-        self.algo = algo
-        self.setWindowTitle(f'Docs for algorithm {algo}')
+    def update_docs(self, generator):
+        self.generator = generator
+        self.setWindowTitle(f'Docs for generator {generator}')
         self.load_docs()
 
     def switch_render_mode(self):
diff --git a/src/badger/gui/default/windows/edit_script_dialog.py b/src/badger/gui/default/windows/edit_script_dialog.py
index ef5f4cfe..29ff5047 100644
--- a/src/badger/gui/default/windows/edit_script_dialog.py
+++ b/src/badger/gui/default/windows/edit_script_dialog.py
@@ -5,10 +5,10 @@
 
 
 class BadgerEditScriptDialog(QDialog):
-    def __init__(self, parent, algo, script, callback):
+    def __init__(self, parent, generator, script, callback):
         super().__init__(parent)
 
-        self.algo = algo
+        self.generator = generator
         self.script = script
         self.callback = callback
 
@@ -16,7 +16,7 @@ def __init__(self, parent, algo, script, callback):
         self.config_logic()
 
     def init_ui(self):
-        self.setWindowTitle(f'Edit Script for {self.algo}')
+        self.setWindowTitle(f'Edit Script for {self.generator}')
         self.resize(640, 640)
 
         vbox = QVBoxLayout(self)
diff --git a/src/badger/gui/default/windows/main_window.py b/src/badger/gui/default/windows/main_window.py
index 4c78ea4b..d187d776 100644
--- a/src/badger/gui/default/windows/main_window.py
+++ b/src/badger/gui/default/windows/main_window.py
@@ -1,5 +1,8 @@
+import os
+import time
 from importlib import metadata
 from PyQt5.QtWidgets import QMainWindow, QStackedWidget, QDesktopWidget
+from PyQt5.QtWidgets import QMessageBox
 # from PyQt5.QtWidgets import QMenuBar, QMenu
 from ..pages.home_page import BadgerHomePage
 
@@ -15,7 +18,10 @@ def init_ui(self):
         version = metadata.version('badger-opt')
         version_xopt = metadata.version('xopt')
         self.setWindowTitle(f'Badger v{version} (Xopt v{version_xopt})')
-        self.resize(960, 720)
+        if os.getenv('DEMO'):
+            self.resize(1280, 720)
+        else:
+            self.resize(960, 720)
         self.center()
 
         # Add menu bar
@@ -42,3 +48,27 @@ def center(self):
 
     def config_logic(self):
         pass
+
+    def closeEvent(self, event):
+        monitor = self.home_page.run_monitor
+        if not monitor.running:
+            monitor.destroy_unused_env()
+            return
+
+        reply = QMessageBox.question(
+            self, 'Window Close',
+            'Closing this window will terminate the current run, '
+            'and the run data would be archived.\n\nProceed?',
+            QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
+
+        if reply == QMessageBox.Yes:
+            def close_window():
+                monitor.destroy_unused_env()
+                self.close()
+
+            monitor.register_post_run_action(close_window)
+            monitor.testing = True  # suppress the archive pop-ups
+            monitor.btn_stop.click()
+            event.ignore()
+        else:
+            event.ignore()
diff --git a/src/badger/gui/default/windows/review_dialog.py b/src/badger/gui/default/windows/review_dialog.py
index 64a6c062..c02193aa 100644
--- a/src/badger/gui/default/windows/review_dialog.py
+++ b/src/badger/gui/default/windows/review_dialog.py
@@ -1,9 +1,11 @@
 from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QTextBrowser, QVBoxLayout
-from ....utils import ystring
+
+from ....routine import Routine
+from ....utils import get_yaml_string
 
 
 class BadgerReviewDialog(QDialog):
-    def __init__(self, parent, routine):
+    def __init__(self, parent, routine: Routine):
         super().__init__(parent)
 
         self.routine = routine
@@ -12,13 +14,13 @@ def __init__(self, parent, routine):
         self.config_logic()
 
     def init_ui(self):
-        name = self.routine['name']
+        name = self.routine.name
         self.setWindowTitle(f'Review routine {name}')
 
         vbox = QVBoxLayout(self)
 
         brow_routine = QTextBrowser()
-        brow_routine.setText(ystring(self.routine))
+        brow_routine.setText(get_yaml_string(self.routine))
 
         self.btn_ok = btn_ok = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
 
diff --git a/src/badger/gui/default/windows/settings_dialog.py b/src/badger/gui/default/windows/settings_dialog.py
index c68e9f70..21c1e38d 100644
--- a/src/badger/gui/default/windows/settings_dialog.py
+++ b/src/badger/gui/default/windows/settings_dialog.py
@@ -70,31 +70,38 @@ def init_ui(self):
         grid.addWidget(archive_root_path, 4, 1)
 
         # Check Variable Interval
-        self.var_int = var_int = QLabel('Check Variable Interval')
-        self.var_int_val = var_int_val = QLineEdit(str(read_value('BADGER_CHECK_VAR_INTERVAL')))
-        self.var_int_val.setValidator(validator)
-        grid.addWidget(var_int, 5, 0)
-        grid.addWidget(var_int_val, 5, 1)
+        # self.var_int = var_int = QLabel('Check Variable Interval')
+        # self.var_int_val = var_int_val = QLineEdit(str(read_value('BADGER_CHECK_VAR_INTERVAL')))
+        # self.var_int_val.setValidator(validator)
+        # grid.addWidget(var_int, 5, 0)
+        # grid.addWidget(var_int_val, 5, 1)
 
         # Check Variable Timeout
-        self.var_time = var_time = QLabel('Check Variable Timeout')
-        self.var_time_val = var_time_val = QLineEdit(str(read_value('BADGER_CHECK_VAR_TIMEOUT')))
-        self.var_time_val.setValidator(validator)
-        grid.addWidget(var_time, 6, 0)
-        grid.addWidget(var_time_val, 6, 1)
+        # self.var_time = var_time = QLabel('Check Variable Timeout')
+        # self.var_time_val = var_time_val = QLineEdit(str(read_value('BADGER_CHECK_VAR_TIMEOUT')))
+        # self.var_time_val.setValidator(validator)
+        # grid.addWidget(var_time, 6, 0)
+        # grid.addWidget(var_time_val, 6, 1)
 
         # Plugin URL
-        self.plugin_url = plugin_url = QLabel('Plugin Server URL')
-        self.plugin_url_name = plugin_url_name = QLineEdit(read_value('BADGER_PLUGINS_URL'))
-        grid.addWidget(plugin_url, 7, 0)
-        grid.addWidget(plugin_url_name, 7, 1)
+        # self.plugin_url = plugin_url = QLabel('Plugin Server URL')
+        # self.plugin_url_name = plugin_url_name = QLineEdit(read_value('BADGER_PLUGINS_URL'))
+        # grid.addWidget(plugin_url, 7, 0)
+        # grid.addWidget(plugin_url_name, 7, 1)
+
+        # Badger data dump period
+        self.dump_period = dump_period = QLabel('Data dump period')
+        self.dump_period_val = dump_period_val = QLineEdit(str(read_value('BADGER_DATA_DUMP_PERIOD')))
+        self.dump_period_val.setValidator(validator)
+        grid.addWidget(dump_period, 8, 0)
+        grid.addWidget(dump_period_val, 8, 1)
 
         # Advanced settings
         self.adv_features = adv_features = QLabel('Enable Advanced Features')
         self.enable_adv_features = enable_adv_features = QCheckBox()
         enable_adv_features.setChecked(strtobool(read_value('BADGER_ENABLE_ADVANCED')))
-        grid.addWidget(adv_features, 8, 0)
-        grid.addWidget(enable_adv_features, 8, 1)
+        grid.addWidget(adv_features, 9, 0)
+        grid.addWidget(enable_adv_features, 9, 1)
 
         grid.setColumnStretch(1, 1)
 
@@ -132,10 +139,13 @@ def apply_settings(self):
         write_value('BADGER_DB_ROOT', self.db_root_path.text())
         write_value('BADGER_LOGBOOK_ROOT', self.logbook_root_path.text())
         write_value('BADGER_ARCHIVE_ROOT', self.archive_root_path.text())
-        write_value('BADGER_CHECK_VAR_INTERVAL', self.var_int_val.text())
-        write_value('BADGER_CHECK_VAR_TIMEOUT', self.var_time_val.text())
-        write_value('BADGER_PLUGINS_URL', self.plugin_url_name.text())
-        write_value('BADGER_ENABLE_ADVANCED', self.enable_adv_features.isChecked())
+        # write_value('BADGER_CHECK_VAR_INTERVAL', self.var_int_val.text())
+        # write_value('BADGER_CHECK_VAR_TIMEOUT', self.var_time_val.text())
+        # write_value('BADGER_PLUGINS_URL', self.plugin_url_name.text())
+        write_value('BADGER_DATA_DUMP_PERIOD',
+                    float(self.dump_period_val.text()))
+        write_value('BADGER_ENABLE_ADVANCED',
+                    self.enable_adv_features.isChecked())
 
     def restore_settings(self):
         # Reset theme if needed
diff --git a/src/badger/gui/default/windows/var_dialog.py b/src/badger/gui/default/windows/var_dialog.py
index 27a5542c..8c024683 100644
--- a/src/badger/gui/default/windows/var_dialog.py
+++ b/src/badger/gui/default/windows/var_dialog.py
@@ -1,7 +1,7 @@
 from PyQt5.QtWidgets import QDialog, QWidget, QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout
 from PyQt5.QtWidgets import QGroupBox, QMessageBox
 from ..components.labeled_lineedit import labeled_lineedit
-from ....core import instantiate_env
+from ....environment import instantiate_env
 
 
 class BadgerVariableDialog(QDialog):
diff --git a/src/badger/log.py b/src/badger/log.py
index ef6db97a..b68d5588 100644
--- a/src/badger/log.py
+++ b/src/badger/log.py
@@ -1,6 +1,8 @@
 import logging
 from logging.config import dictConfig
-from .utils import merge_params
+from badger.utils import merge_params
+
+logger = logging.getLogger(__name__)
 
 
 LOG_CONFIG_DEFAULT = {
diff --git a/src/badger/logbook.py b/src/badger/logbook.py
index 7455c827..bf742cf2 100644
--- a/src/badger/logbook.py
+++ b/src/badger/logbook.py
@@ -26,19 +26,19 @@ def send_to_logbook(routine, data, widget=None):
 
     log_text = ''
     routine_name = routine['name']
-    algo_name = routine['algo']
+    generator_name = routine['generator']
     data_path = BADGER_ARCHIVE_ROOT
     obj_name = obj_names[0]
     obj_start = data[obj_name][0]
     obj_end = data[obj_name][-1]
-    duration = data['timestamp_raw'][-1] - data['timestamp_raw'][0]
-    n_point = len(data['timestamp_raw'])
+    duration = data['timestamp'][-1] - data['timestamp'][0]
+    n_point = len(data['timestamp'])
     if n_point > 0:
         log_text = f'Gain ({obj_name}): {round(obj_start, 4)} -> {round(obj_end, 4)}\n'
     log_text += f'Time cost: {round(duration, 2)}s\n'
     log_text += f'Points requested: {n_point}\n'
     log_text += f'Routine name: {routine_name}\n'
-    log_text += f'Type of optimization: {algo_name}\n'
+    log_text += f'Type of optimization: {generator_name}\n'
     log_text += f'Data location: {data_path}\n'
     try:
         log_text += f'Log location: {BADGER_LOGBOOK_ROOT}\n'
diff --git a/src/badger/routine.py b/src/badger/routine.py
new file mode 100644
index 00000000..0b9786dd
--- /dev/null
+++ b/src/badger/routine.py
@@ -0,0 +1,141 @@
+import json
+from copy import deepcopy
+from typing import Optional, List, Any
+
+import pandas as pd
+from pandas import DataFrame
+from pydantic import ConfigDict, Field, model_validator, field_validator, \
+    ValidationInfo, SerializeAsAny
+from xopt import Xopt, VOCS, Evaluator
+from xopt.generators import get_generator
+from badger.utils import curr_ts
+from badger.environment import Environment, instantiate_env
+
+
+class Routine(Xopt):
+
+    name: str
+    description: Optional[str] = Field(None)
+    environment: SerializeAsAny[Environment]
+    initial_points: Optional[DataFrame] = Field(None)
+    critical_constraint_names: Optional[List[str]] = Field([])
+    tags: Optional[List] = Field(None)
+    script: Optional[str] = Field(None)
+
+    model_config = ConfigDict(arbitrary_types_allowed=True)
+
+    @model_validator(mode="before")
+    @classmethod
+    def validate_model(cls, data: Any):
+        from badger.factory import get_env
+
+        if isinstance(data, dict):
+            # validate vocs
+            if isinstance(data["vocs"], dict):
+                data["vocs"] = VOCS(**data["vocs"])
+
+            # validate generator
+            if isinstance(data["generator"], dict):
+                name = data["generator"].pop("name")
+                generator_class = get_generator(name)
+                data["generator"] = generator_class.model_validate(
+                    {**data["generator"], "vocs": data["vocs"]}
+                )
+            elif isinstance(data["generator"], str):
+                generator_class = get_generator(data["generator"])
+
+                data["generator"] = generator_class.model_validate(
+                    {"vocs": data["vocs"]}
+                )
+
+            # validate data (if it exists
+            if "data" in data:
+                if isinstance(data["data"], dict):
+                    try:
+                        data["data"] = pd.DataFrame(data["data"])
+                    except IndexError:
+                        data["data"] = pd.DataFrame(data["data"], index=[0])
+
+                    data["generator"].add_data(data["data"])
+
+            # instantiate env
+            if isinstance(data["environment"], dict):
+                # TODO: Actually we need this interface info, but
+                # should be put somewhere else (in parallel with env?)
+                try:
+                    del data["environment"]["interface"]
+                except KeyError:  # no interface at all, which is good
+                    pass
+                name = data["environment"].pop("name")
+                env_class, configs_env = get_env(name)
+                configs_env["params"] |= data["environment"]
+                data["environment"] = instantiate_env(env_class, configs_env)
+            else:  # should be an instantiated env already
+                pass
+
+            # create evaluator
+            env = data["environment"]
+
+            def evaluate_point(point: dict):
+                # sanitize inputs
+                point = pd.Series(point).explode().to_dict()
+                env._set_variables(point)
+                obs = env._get_observables(data["vocs"].output_names)
+
+                ts = curr_ts()
+                obs['timestamp'] = ts.timestamp()
+
+                return obs
+
+            data["evaluator"] = Evaluator(function=evaluate_point)
+
+        return data
+
+    @field_validator("initial_points", mode="before")
+    def validate_data(cls, v, info: ValidationInfo):
+        if isinstance(v, dict):
+            try:
+                v = pd.DataFrame(v)
+            except IndexError:
+                v = pd.DataFrame(v, index=[0])
+
+        return v
+
+    @property
+    def sorted_data(self):
+        data_copy = deepcopy(self.data)
+        data_copy.index = data_copy.index.astype(int)
+        data_copy.sort_index(inplace=True)
+
+        return data_copy
+
+    def json(self, **kwargs) -> str:
+        """Handle custom serialization of environment"""
+
+        result = super().json(**kwargs)
+        dict_result = json.loads(result)
+
+        # Remove extra fields
+        fields_to_be_removed = [
+            "dump_file",
+            "evaluator",
+            "max_evaluations",
+            "serialize_inline",
+            "serialize_torch",
+            "strict"
+        ]
+        for field in fields_to_be_removed:
+            dict_result.pop(field, None)
+
+        dict_result["environment"] = {"name": self.environment.name} |\
+            dict_result["environment"]
+        try:
+            dict_result["environment"]["interface"] = {
+                "name": self.environment.interface.name} |\
+                dict_result["environment"]["interface"]
+        except KeyError:
+            pass
+        except AttributeError:
+            pass
+
+        return json.dumps(dict_result)
diff --git a/src/badger/settings.py b/src/badger/settings.py
index 46707415..d7f293cb 100644
--- a/src/badger/settings.py
+++ b/src/badger/settings.py
@@ -1,80 +1,84 @@
+import os
+import shutil
+from importlib import resources
 from PyQt5.QtCore import QSettings
+from .utils import get_datadir
 
 
 BADGER_PATH_DICT = {
-    'BADGER_PLUGIN_ROOT': {
-        'display name': 'plugin root',
-        'description': 'This setting (BADGER_PLUGIN_ROOT) tells Badger where to look for the plugins',
-        'default value': None,
+    "BADGER_PLUGIN_ROOT": {
+        "display name": "plugin root",
+        "description": "This setting (BADGER_PLUGIN_ROOT) tells Badger where to look for the plugins",
+        "default value": None,
     },
-    'BADGER_DB_ROOT': {
-        'display name': 'database root',
-        'description': 'This setting (BADGER_DB_ROOT) tells Badger where to store the routine database',
-        'default value': None,
+    "BADGER_DB_ROOT": {
+        "display name": "database root",
+        "description": "This setting (BADGER_DB_ROOT) tells Badger where to store the routine database",
+        "default value": None,
     },
-    'BADGER_LOGBOOK_ROOT': {
-        'display name': 'logbook root',
-        'description': 'This setting (BADGER_LOGBOOK_ROOT) tells Badger where to send the logs (GUI mode)',
-        'default value': None,
+    "BADGER_LOGBOOK_ROOT": {
+        "display name": "logbook root",
+        "description": "This setting (BADGER_LOGBOOK_ROOT) tells Badger where to send the logs (GUI mode)",
+        "default value": None,
     },
-    'BADGER_ARCHIVE_ROOT': {
-        'display name': 'archive root',
-        'description': 'This setting (BADGER_ARCHIVE_ROOT) tells Badger where to archive the historical optimization runs',
-        'default value': None,
+    "BADGER_ARCHIVE_ROOT": {
+        "display name": "archive root",
+        "description": "This setting (BADGER_ARCHIVE_ROOT) tells Badger where to archive the historical optimization runs",
+        "default value": None,
     },
 }
 
 
 BADGER_CORE_DICT = {
-    'BADGER_CHECK_VAR_INTERVAL': {
-        'display name': 'check var interval',
-        'description': 'Waiting time between each round of check var when set var to env, unit is second',
-        'default value': 0.1,
+    # 'BADGER_CHECK_VAR_INTERVAL': {
+    #     'display name': 'check var interval',
+    #     'description': 'Waiting time between each round of check var when set var to env, unit is second',
+    #     'default value': 0.1,
+    # },
+    # 'BADGER_CHECK_VAR_TIMEOUT': {
+    #     'display name': 'check var timeout',
+    #     'description': 'Maximum waiting time before giving up check var if takes too long time, unit is second',
+    #     'default value': 3,
+    # },
+    "BADGER_DATA_DUMP_PERIOD": {
+        "display name": "data dump period",
+        "description": "Minimum time interval between data dumps, unit is second",
+        "default value": 1,
     },
-    'BADGER_CHECK_VAR_TIMEOUT': {
-        'display name': 'check var timeout',
-        'description': 'Maximum waiting time before giving up check var if takes too long time, unit is second',
-        'default value': 3,
-    },
-    'BADGER_DATA_DUMP_PERIOD': {
-        'display name': 'data dump period',
-        'description': 'Minimum time interval between data dumps, unit is second',
-        'default value': 1,
-    },
-    'BADGER_PLUGINS_URL': {
-        'display name': 'badger plugins url',
-        'description': 'URL for badger plugins server',
-        'default value': 'http://teeport.ml/badger-plugins'
-    }
+    # 'BADGER_PLUGINS_URL': {
+    #     'display name': 'badger plugins url',
+    #     'description': 'URL for badger plugins server',
+    #     'default value': 'http://teeport.ml/badger-plugins'
+    # }
 }
 
 
 BADGER_GUI_DICT = {
-    'BADGER_THEME': {
-        'display name': 'theme',
-        'description': 'Theme for the Badger GUI',
-        'default value': 'dark',
+    "BADGER_THEME": {
+        "display name": "theme",
+        "description": "Theme for the Badger GUI",
+        "default value": "dark",
     },
-    'BADGER_ENABLE_ADVANCED': {
-        'display name': 'enable advanced features',
-        'description': 'Enable advanced features on the GUI',
-        'default value': False,
+    "BADGER_ENABLE_ADVANCED": {
+        "display name": "enable advanced features",
+        "description": "Enable advanced features on the GUI",
+        "default value": False,
     },
 }
 
 
 def init_settings():
-    settings = QSettings('SLAC-ML', 'Badger')
+    settings = QSettings("SLAC-ML", "Badger")
 
     for key in BADGER_PATH_DICT.keys():
         if settings.value(key) is None:
-            settings.setValue(key, BADGER_PATH_DICT[key]['default value'])
+            settings.setValue(key, BADGER_PATH_DICT[key]["default value"])
     for key in BADGER_CORE_DICT.keys():
         if settings.value(key) is None:
-            settings.setValue(key, BADGER_CORE_DICT[key]['default value'])
+            settings.setValue(key, BADGER_CORE_DICT[key]["default value"])
     for key in BADGER_GUI_DICT.keys():
         if settings.value(key) is None:
-            settings.setValue(key, BADGER_GUI_DICT[key]['default value'])
+            settings.setValue(key, BADGER_GUI_DICT[key]["default value"])
 
 
 def list_settings():
@@ -87,7 +91,7 @@ def list_settings():
         settings, the value for each key is the current value for that setting.
 
     """
-    settings = QSettings('SLAC-ML', 'Badger')
+    settings = QSettings("SLAC-ML", "Badger")
     result = {}
     for key in BADGER_PATH_DICT.keys():
         result[key] = settings.value(key)
@@ -100,12 +104,54 @@ def list_settings():
 
 
 def read_value(key):
-    settings = QSettings('SLAC-ML', 'Badger')
+    settings = QSettings("SLAC-ML", "Badger")
 
     return settings.value(key)
 
 
 def write_value(key, value):
-    settings = QSettings('SLAC-ML', 'Badger')
+    settings = QSettings("SLAC-ML", "Badger")
 
     settings.setValue(key, value)
+
+
+def mock_settings():
+    app_data_dir = get_datadir() / "Badger"
+    os.makedirs(app_data_dir, exist_ok=True)
+
+    settings = QSettings("SLAC-ML", "Badger")
+
+    # Configure the paths and put/refresh the mock plugins there if needed
+    plugins_dir = str(app_data_dir / "plugins")
+    os.makedirs(plugins_dir, exist_ok=True)
+    settings.setValue("BADGER_PLUGIN_ROOT", plugins_dir)
+    built_in_plugins_dir = resources.files(__package__) / "built_in_plugins"
+    shutil.copytree(built_in_plugins_dir, plugins_dir, dirs_exist_ok=True)
+
+    db_dir = str(app_data_dir / "db")
+    os.makedirs(db_dir, exist_ok=True)
+    settings.setValue("BADGER_DB_ROOT", db_dir)
+
+    logbook_dir = str(app_data_dir / "logbook")
+    os.makedirs(logbook_dir, exist_ok=True)
+    settings.setValue("BADGER_LOGBOOK_ROOT", logbook_dir)
+
+    archive_dir = str(app_data_dir / "archive")
+    os.makedirs(archive_dir, exist_ok=True)
+    settings.setValue("BADGER_ARCHIVE_ROOT", archive_dir)
+
+    # Set other settings to the default values
+    for key in BADGER_CORE_DICT.keys():
+        settings.setValue(key, BADGER_CORE_DICT[key]["default value"])
+    for key in BADGER_GUI_DICT.keys():
+        settings.setValue(key, BADGER_GUI_DICT[key]["default value"])
+
+
+def reset_settings():
+    settings = QSettings("SLAC-ML", "Badger")
+    for key in BADGER_PATH_DICT.keys():
+        settings.setValue(key, BADGER_PATH_DICT[key]["default value"])
+    for key in BADGER_CORE_DICT.keys():
+        settings.setValue(key, BADGER_CORE_DICT[key]["default value"])
+    for key in BADGER_GUI_DICT.keys():
+        settings.setValue(key, BADGER_GUI_DICT[key]["default value"])
diff --git a/src/badger/utils.py b/src/badger/utils.py
index aa6a2501..37c97e94 100644
--- a/src/badger/utils.py
+++ b/src/badger/utils.py
@@ -1,9 +1,10 @@
 import json
 import logging
 import os
+import sys
+import pathlib
 from datetime import datetime
 
-import numpy as np
 import yaml
 
 from .errors import BadgerLoadConfigError
@@ -18,7 +19,7 @@ def increase_indent(self, flow=False, indentless=False):
         return super(Dumper, self).increase_indent(flow, False)
 
 
-def ystring(content):
+def get_yaml_string(content):
     if content is None:
         return ""
 
@@ -27,7 +28,7 @@ def ystring(content):
 
 
 def yprint(content):
-    print(ystring(content), end="")
+    print(get_yaml_string(content), end="")
 
 
 def norm(x, lb, ub):
@@ -106,35 +107,6 @@ def range_to_str(vranges):
     return vranges_str
 
 
-class ParetoFront:
-    def __init__(self, rules):
-        # rules: ['MAXIMIZE', 'MINIMIZE', ...]
-        self.rules = (np.array(rules) == "MINIMIZE") * 2 - 1
-        self.dimension = len(rules)
-        self.pareto_set = None
-        self.pareto_front = None
-
-    def is_dominated(self, candidate):
-        # candidate: (x: array-like, y: array-like)
-        # First candidate
-        if self.pareto_front is None:
-            self.pareto_set = np.array(candidate[0]).reshape(1, -1)
-            self.pareto_front = np.array(candidate[1]).reshape(1, -1)
-            return False
-
-        dmat = (self.pareto_front - candidate[1]) * self.rules > 0
-        scores = np.sum(dmat, axis=1)
-        if np.sum(scores == 0):  # candidate is dominated
-            return True
-
-        # Drop points that are dominated by candidate
-        idx_keep = scores != self.dimension
-        self.pareto_front = np.vstack((self.pareto_front[idx_keep],
-                                       candidate[1]))
-        self.pareto_set = np.vstack((self.pareto_set[idx_keep], candidate[0]))
-        return False
-
-
 def ts_to_str(ts, format="lcls-log"):
     if format == "lcls-log":
         return ts.strftime("%d-%b-%Y %H:%M:%S")
@@ -172,23 +144,19 @@ def curr_ts_to_str(format="lcls-log"):
 
 def get_header(routine):
     try:
-        obj_names = [next(iter(d)) for d in routine["config"]["objectives"]]
+        obj_names = routine.vocs.objective_names
     except Exception:
         obj_names = []
     try:
-        var_names = [next(iter(d)) for d in routine["config"]["variables"]]
+        var_names = routine.vocs.variable_names
     except Exception:
         var_names = []
     try:
-        if routine["config"]["constraints"]:
-            con_names = [next(iter(d)) for d in
-                         routine["config"]["constraints"]]
-        else:
-            con_names = []
+        con_names = routine.vocs.constraint_names
     except Exception:
         con_names = []
     try:
-        sta_names = routine["config"]["states"] or []
+        sta_names = routine.vocs.constant_names
     except KeyError:
         sta_names = []
 
@@ -324,3 +292,25 @@ def strtobool(val):
         return False
     else:
         raise ValueError("invalid truth value %r" % (val,))
+
+
+# https://stackoverflow.com/a/61901696/4263605
+def get_datadir() -> pathlib.Path:
+
+    """
+    Returns a parent directory path
+    where persistent application data can be stored.
+
+    # linux: ~/.local/share
+    # macOS: ~/Library/Application Support
+    # windows: C:/Users/<USER>/AppData/Roaming
+    """
+
+    home = pathlib.Path.home()
+
+    if sys.platform == "win32":
+        return home / "AppData/Roaming"
+    elif sys.platform == "linux":
+        return home / ".local/share"
+    elif sys.platform == "darwin":
+        return home / "Library/Application Support"


Test patch:
diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml
deleted file mode 100644
index 7f8d5fdb..00000000
--- a/.github/workflows/run-test.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-# This workflow will install badger dependencies, lint with flake8, and run the test suite, for all combinations
-# of operating systems and version numbers specified in the matrix
-
-name: Build Status
-
-on:
-  push:
-    branches: [ "**" ]
-  pull_request:
-    branches: [ "**" ]
-
-permissions:
-  contents: read
-
-jobs:
-  build:
-    if: ${{ github.repository == 'slaclab/Badger' }}
-    runs-on: ${{ matrix.os }}
-    strategy:
-      fail-fast: false
-      matrix:
-        os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: [3.9, 3.11]
-    env:
-      DISPLAY: ':99.0'
-      QT_MAC_WANTS_LAYER: 1  # PyQT gui tests involving qtbot interaction on macOS will fail without this
-
-    steps:
-    - uses: actions/checkout@v3
-    - name: Setup conda
-      uses: conda-incubator/setup-miniconda@v2
-      with:
-        python-version: ${{ matrix.python-version }}
-        miniforge-variant: Mambaforge
-        miniforge-version: latest
-        activate-environment: badger-env
-        environment-file: environment.yml
-    - name: Install python packages
-      shell: bash -el {0}
-      run: |
-        if [ "$RUNNER_OS" == "Windows" ]; then
-          mamba install flake8 zipp 
-          mamba install --file requirements.txt --file windows-dev-requirements.txt
-        else
-          mamba install flake8 zipp $(cat requirements.txt dev-requirements.txt)
-        fi
-    - name: Install pyqt5
-      shell: bash -el {0}
-      run: |
-          pip install PyQt5
-    - name: Install packages for testing a pyqt app on linux
-      shell: bash -el {0}
-      run: |
-        if [ "$RUNNER_OS" == "Linux" ]; then
-          sudo apt install xvfb herbstluftwm libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils
-          sudo /sbin/start-stop-daemon --start --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset
-          sleep 3
-          sudo /sbin/start-stop-daemon --start --pidfile /tmp/custom_herbstluftwm_99.pid --make-pidfile --background --exec /usr/bin/herbstluftwm
-          sleep 1
-        fi
-    - name: Test with pytest
-      shell: bash -el {0}
-      run: |
-        python run_tests.py
diff --git a/run_tests.py b/scripts/run_tests.py
similarity index 91%
rename from run_tests.py
rename to scripts/run_tests.py
index 858f467c..c1e476df 100644
--- a/run_tests.py
+++ b/scripts/run_tests.py
@@ -1,7 +1,9 @@
 #!/usr/bin/env python
 import sys
+
 import pytest
 
+
 if __name__ == "__main__":
     # Show output results from every test function
     # Show the message output for skipped and expected failures
@@ -17,5 +19,5 @@
         args.remove("--show-cov")
 
     print("pytest arguments: {}".format(args))
-
+    print(f"Running tests on Python {sys.version}")
     sys.exit(pytest.main(args))
diff --git a/src/badger/tests/mock/plugins/environments/multiobjective_test/README.md b/src/badger/tests/mock/plugins/environments/multiobjective_test/README.md
new file mode 100644
index 00000000..959a29f7
--- /dev/null
+++ b/src/badger/tests/mock/plugins/environments/multiobjective_test/README.md
@@ -0,0 +1,5 @@
+# High-dimensional Sphere Test Environment for Badger
+
+## Prerequisites
+
+## Usage
diff --git a/src/badger/tests/mock/plugins/environments/multiobjective_test/__init__.py b/src/badger/tests/mock/plugins/environments/multiobjective_test/__init__.py
new file mode 100644
index 00000000..86119e63
--- /dev/null
+++ b/src/badger/tests/mock/plugins/environments/multiobjective_test/__init__.py
@@ -0,0 +1,26 @@
+import torch
+from badger import environment
+from badger.errors import BadgerNoInterfaceError
+
+
+class Environment(environment.Environment):
+
+    name = 'multiobjective_test'
+    variables = {f'x{i}': [-1, 1] for i in range(20)}
+    observables = ['f1', "f2"]
+
+    flag: int = 0
+
+    def set_variables(self, variable_inputs: dict[str, float]):
+        if not self.interface:
+            raise BadgerNoInterfaceError
+
+        self.interface.set_values(variable_inputs)
+        full_outputs = self.interface.get_values(self.variable_names)
+
+        # Filling up the observations
+        x = torch.tensor([full_outputs[f'x{i}'] for i in range(20)])
+        self.interface.set_values({
+            'f1': float((x ** 2).sum().numpy()),
+            'f2': float((x ** 3).sum().numpy()),
+        })
diff --git a/src/badger/tests/mock/plugins/environments/multiobjective_test/configs.yaml b/src/badger/tests/mock/plugins/environments/multiobjective_test/configs.yaml
new file mode 100644
index 00000000..1b237aea
--- /dev/null
+++ b/src/badger/tests/mock/plugins/environments/multiobjective_test/configs.yaml
@@ -0,0 +1,9 @@
+---
+name: test
+description: "mock"
+version: "1.0"
+dependencies:
+  - torch
+  - badger-opt
+interface:
+  - test
diff --git a/src/badger/tests/mock/plugins/environments/test/__init__.py b/src/badger/tests/mock/plugins/environments/test/__init__.py
index 041d350a..94d55c3b 100644
--- a/src/badger/tests/mock/plugins/environments/test/__init__.py
+++ b/src/badger/tests/mock/plugins/environments/test/__init__.py
@@ -1,5 +1,4 @@
 import torch
-from typing import Dict
 from badger import environment
 from badger.errors import BadgerNoInterfaceError
 
@@ -8,11 +7,11 @@ class Environment(environment.Environment):
 
     name = 'test'
     variables = {f'x{i}': [-1, 1] for i in range(20)}
-    observables = ['f']
+    observables = ['f', "c"]
 
     flag: int = 0
 
-    def set_variables(self, variable_inputs: Dict[str, float]):
+    def set_variables(self, variable_inputs: dict[str, float]):
         if not self.interface:
             raise BadgerNoInterfaceError
 
@@ -21,4 +20,5 @@ def set_variables(self, variable_inputs: Dict[str, float]):
 
         # Filling up the observations
         x = torch.tensor([full_outputs[f'x{i}'] for i in range(20)])
-        self.interface.set_value('f', (x ** 2).sum().numpy())
+        self.interface.set_value('f', float((x ** 2).sum().numpy()))
+        self.interface.set_value('c', float((x ** 2).sum().numpy()))
diff --git a/src/badger/tests/mock/plugins/environments/test/configs.yaml b/src/badger/tests/mock/plugins/environments/test/configs.yaml
index 3e45a48b..1b237aea 100644
--- a/src/badger/tests/mock/plugins/environments/test/configs.yaml
+++ b/src/badger/tests/mock/plugins/environments/test/configs.yaml
@@ -1,6 +1,6 @@
 ---
 name: test
-description: "High-dimensional sphere test environment"
+description: "mock"
 version: "1.0"
 dependencies:
   - torch
diff --git a/src/badger/tests/test_cli_basic.py b/src/badger/tests/test_cli_basic.py
index cd9d4876..e10dd36f 100644
--- a/src/badger/tests/test_cli_basic.py
+++ b/src/badger/tests/test_cli_basic.py
@@ -19,7 +19,7 @@ def test_cli_main():
 
     # Check output lines
     outlines = out.split('\n')
-    assert len(outlines) == 8
+    assert len(outlines) == 7
 
     # Check name
     assert outlines[0] == 'name: Badger the optimizer'
@@ -29,6 +29,17 @@ def test_cli_main():
     assert outlines[1] == f'version: {version}'
 
 
+def test_list_algo():
+    command = ['badger', 'generator']
+    out, err, exitcode = capture(command)
+
+    assert exitcode == 0
+
+    # Check output lines
+    outlines = out.split('\n')
+    assert '- upper_confidence_bound' in outlines
+
+
 # def test_cli_run(mock_config_root):
 #     command = ['badger', 'run', '-a', 'upper_confidence_bound', '-ap',
 #                '{max_evaluations: 10}',  '-e', 'silly', '-c',
diff --git a/src/badger/tests/test_core.py b/src/badger/tests/test_core.py
index 4443f1f1..4d2223ce 100644
--- a/src/badger/tests/test_core.py
+++ b/src/badger/tests/test_core.py
@@ -1,16 +1,6 @@
 import os
-import copy
 import pytest
 import pandas as pd
-from typing import Type
-from xopt.generators import get_generator
-from badger.utils import merge_params, ParetoFront
-from badger.core import (
-    run_routine_xopt,
-    evaluate_points,
-    Routine,
-    instantiate_env,
-)
 from badger.errors import BadgerRunTerminatedError
 
 
@@ -23,7 +13,6 @@ class TestCore:
     def test_core_setup(self, *args, **kwargs) -> None:
         super(TestCore, self).__init__(*args, **kwargs)
         self.count = 0
-        self.points_eval = None
         self.candidates = None
         self.points_eval_list = []
         self.candidates_list = []
@@ -42,114 +31,6 @@ def test_core_setup(self, *args, **kwargs) -> None:
 
         self.points_eval_target = pd.DataFrame(data_eval_target)
 
-        self.test_evaluate_points_cases = [
-            (
-                self.points,
-                self.mock_routine,
-                self.mock_evaluate_points_callback,
-                self.points_eval_target,
-            )
-        ]
-
-    def mock_routine(self) -> Type[Routine]:
-        """
-        A method that creates a Routine class object
-        filled with sample data for testing purposes.
-        """
-        from badger.factory import get_env
-
-        test_routine = {
-            "name": "routine-for-core-test",
-            "algo": "upper_confidence_bound",
-            "env": "test",
-            "algo_params": {
-                "model": None,
-                "turbo_controller": None,
-                "use_cuda": False,
-                "gp_constructor": {
-                    "name": "standard",
-                    "use_low_noise_prior": True,
-                    "covar_modules": {},
-                    "mean_modules": {},
-                    "trainable_mean_keys": [],
-                },
-                "numerical_optimizer": {
-                    "name": "LBFGS",
-                    "n_restarts": 20,
-                    "max_iter": 2000,
-                },
-                "max_travel_distances": None,
-                "fixed_features": None,
-                "n_candidates": 1,
-                "n_monte_carlo_samples": 128,
-                "beta": 2.0,
-                "start_from_current": True,
-            },
-            "env_params": {},
-            "config": {
-                "variables": [
-                    {"x0": [-1, 1]},
-                    {"x1": [-1, 1]},
-                    {"x2": [-1, 1]},
-                    {"x3": [-1, 1]},
-                ],
-                "objectives": [{"f": "MAXIMIZE"}],
-                "constraints": None,
-                "states": None,
-                "domain_scaling": None,
-                "tags": None,
-                "init_points": {
-                    "x0": [0.5],
-                    "x1": [0.5],
-                    "x2": [0.5],
-                    "x3": [0.5],
-                },
-            },
-        }
-
-        # Initialize routine
-        Environment, configs_env = get_env(test_routine["env"])
-        _configs_env = merge_params(
-            configs_env, {"params": test_routine["env_params"]}
-        )
-        environment = instantiate_env(Environment, _configs_env)
-
-        variables = {
-            key: value
-            for dictionary in test_routine["config"]["variables"]
-            for key, value in dictionary.items()
-        }
-        objectives = {
-            key: value
-            for dictionary in test_routine["config"]["objectives"]
-            for key, value in dictionary.items()
-        }
-        vocs = {
-            "variables": variables,
-            "objectives": objectives,
-        }
-
-        generator_class = get_generator(test_routine["algo"])
-
-        del test_routine["algo_params"]["start_from_current"]
-        del test_routine["algo_params"]["n_candidates"]
-        del test_routine["algo_params"]["fixed_features"]
-
-        test_routine_copy = copy.deepcopy(test_routine["algo_params"])
-
-        generator = generator_class(vocs=vocs, **test_routine_copy)
-
-        initial_points = test_routine["config"]["init_points"]
-        initial_points = pd.DataFrame.from_dict(initial_points)
-
-        test_routine_xopt = Routine(
-            environment=environment,
-            generator=generator,
-            initial_points=initial_points,
-        )
-
-        return test_routine_xopt
-
     def mock_active_callback(self) -> int:
         """
         A mock active callback method to test
@@ -175,13 +56,6 @@ def mock_evaluate_callback(self, points_eval: pd.DataFrame) -> None:
         self.points_eval_list.append(points_eval)
         self.count += 1
 
-    def mock_pf_callback(self, pf: Type[ParetoFront]) -> None:
-        """
-        A mock pareto callback method to test
-        if pareto callback is functioning properly.
-        """
-        self.pf = pf
-
     def mock_states_callback(self, states: dict) -> None:
         """
         A mock states callback method to test
@@ -196,20 +70,24 @@ def mock_dump_file_callback(self) -> str:
         """
         return "test.yaml"
 
-    def test_run_routine_xopt(self) -> None:
+    def test_run_routine(self) -> None:
         """
         A unit test to ensure the core functionality
         of run_routine_xopt is functioning as intended.
         """
-        routine = self.mock_routine()
+        from badger.core import run_routine
+        from badger.tests.utils import create_routine
+
+        routine = create_routine()
+
+        self.count = 0
 
         with pytest.raises(BadgerRunTerminatedError):
-            run_routine_xopt(
+            run_routine(
                 routine,
                 self.mock_active_callback,
                 self.mock_generate_callback,
                 self.mock_evaluate_callback,
-                self.mock_pf_callback,
                 self.mock_states_callback,
                 self.mock_dump_file_callback,
             )
@@ -217,38 +95,53 @@ def test_run_routine_xopt(self) -> None:
         assert len(self.candidates_list) == self.count - 1
         assert len(self.points_eval_list) == self.count
 
-        assert self.pf is not None
         assert self.states is None
 
         path = "./test.yaml"
         assert os.path.exists(path) is True
         os.remove("./test.yaml")
 
-    def mock_evaluate_points_callback(self, points_eval: pd.DataFrame) -> None:
-        """
-        A mock callback method to test if callback method passed
-        to the evaluate_points method is functioning properly.
-        """
-        self.points_eval = points_eval
-
     def test_evaluate_points(self) -> None:
         """
         A unit test to ensure the core functionality of evaluate_points
         is functioning as intended.
         """
-        routine = self.mock_routine()
+        from badger.tests.utils import create_routine
+
+        routine = create_routine()
 
         assert routine.environment.get_variables(["x1", "x2"]) == {
             "x1": 0,
             "x2": 0,
         }
-        evaluate_points_result = evaluate_points(
-            self.points, routine, self.mock_evaluate_points_callback
-        )
+        evaluate_points_result = routine.evaluate_data(self.points)
 
-        assert evaluate_points_result.astype(float).equals(
-            self.points_eval_target.astype(float)
-        )
-        assert self.points_eval.astype(float).equals(
+        vocs_list = ['x0', 'x1', 'x2', 'x3', 'f']
+        assert evaluate_points_result[vocs_list].astype(float).equals(
             self.points_eval_target.astype(float)
         )
+
+    def test_run_turbo(self) -> None:
+        """
+        A unit test to ensure TuRBO can run in Badger.
+        """
+        from badger.core import run_routine
+        from badger.tests.utils import create_routine_turbo
+
+        routine = create_routine_turbo()
+        assert routine.generator.turbo_controller.best_value is None
+
+        self.count = 0
+
+        with pytest.raises(BadgerRunTerminatedError):
+            run_routine(
+                routine,
+                self.mock_active_callback,
+                self.mock_generate_callback,
+                self.mock_evaluate_callback,
+                self.mock_states_callback,
+                dump_file_callback=None,
+            )
+
+        assert len(self.candidates_list) == self.count - 1
+        assert len(self.points_eval_list) == self.count
diff --git a/src/badger/tests/test_data_viewer_extension.py b/src/badger/tests/test_data_viewer_extension.py
new file mode 100644
index 00000000..45ce9a14
--- /dev/null
+++ b/src/badger/tests/test_data_viewer_extension.py
@@ -0,0 +1,9 @@
+def test_data_viewer_simple(qtbot):
+    from badger.gui.default.components.analysis_extensions import AnalysisExtension
+    from badger.tests.utils import create_routine
+
+    routine = create_routine()
+    routine.evaluate_data(routine.initial_points)
+
+    data_viewer = AnalysisExtension()
+    data_viewer.update_window(routine)
diff --git a/src/badger/tests/test_db.py b/src/badger/tests/test_db.py
new file mode 100644
index 00000000..9e1b7fcd
--- /dev/null
+++ b/src/badger/tests/test_db.py
@@ -0,0 +1,29 @@
+import os
+
+
+class TestDB:
+    def test_save_routine(self):
+        from badger.db import save_routine, remove_routine
+        from badger.tests.utils import create_routine, fix_db_path_issue
+
+        fix_db_path_issue()
+
+        routine = create_routine()
+        save_routine(routine)
+
+        remove_routine("test")
+
+    def test_load_routine(self):
+        from badger.db import save_routine, load_routine, remove_routine
+        from badger.tests.utils import create_routine, fix_db_path_issue
+
+        fix_db_path_issue()
+
+        routine = create_routine()
+        save_routine(routine)
+
+        new_routine, _ = load_routine("test")
+        assert new_routine.generator == routine.generator
+        assert new_routine.vocs == routine.vocs
+
+        remove_routine("test")
diff --git a/src/badger/tests/test_env.py b/src/badger/tests/test_env.py
index 7a3758b9..f0efe6a5 100644
--- a/src/badger/tests/test_env.py
+++ b/src/badger/tests/test_env.py
@@ -4,7 +4,7 @@
 def test_find_env():
     from badger.factory import list_env, get_env
 
-    assert len(list_env()) == 1
+    assert len(list_env()) == 2
 
     _, configs = get_env('test')
     assert configs['name'] == 'test'
@@ -57,7 +57,7 @@ def test_list_observables():
     Environment, _ = get_env("test")
     env = Environment()
 
-    assert env.observables == ['f']
+    assert env.observables == ['f', "c"]
 
 
 def test_get_variables():
diff --git a/src/badger/tests/test_environment.py b/src/badger/tests/test_environment.py
new file mode 100644
index 00000000..585284e3
--- /dev/null
+++ b/src/badger/tests/test_environment.py
@@ -0,0 +1,89 @@
+import json
+from typing import Dict, List
+
+from xopt.resources.testing import TEST_VOCS_BASE
+
+
+class TestEnv:
+
+    def test_basic_env(self):
+        from badger.environment import Environment
+
+        class Environment(Environment):
+            name = 'test'
+            variables = {f'x{i}': [-1, 1] for i in range(20)}
+            observables = ['f']
+
+            my_flag: int = 0
+
+            def set_variables(self, variable_inputs: Dict[str, float]):
+                pass
+
+            def get_observables(self, observable_names: List[str]) -> Dict:
+                return {ele: 1.0 for ele in observable_names}
+
+        env = Environment()
+        result = dict(env)
+        assert result["my_flag"] == 0
+
+        env.my_flag = 1
+        result = dict(env)
+        assert result["my_flag"] == 1
+
+        result = json.loads(env.model_dump_json())
+        assert result["my_flag"] == 1
+
+    def test_env_in_routine(self):
+        from badger.environment import Environment
+
+        class Environment(Environment):
+            name = 'test'
+            variables = {f'x{i}': [-1, 1] for i in range(20)}
+            observables = ['f']
+
+            my_flag: int = 0
+
+            def set_variables(self, variable_inputs: Dict[str, float]):
+                pass
+
+            def get_observables(self, observable_names: List[str]) -> Dict:
+                return {ele: 1.0 for ele in observable_names}
+
+        from badger.routine import Routine
+
+        env = Environment()
+        vocs = TEST_VOCS_BASE
+
+        routine = Routine(
+            name="test_routine",
+            environment=env,
+            vocs=vocs,
+            generator="random"
+        )
+        result = json.loads(routine.json())
+        assert result["environment"]["my_flag"] == 0
+
+        routine.environment.my_flag = 1
+        result = json.loads(routine.json())
+        assert result["environment"]["my_flag"] == 1
+
+    def test_env_from_get_env(self):
+        from badger.factory import get_env
+        from badger.routine import Routine
+
+        env, config = get_env("test")
+        config.pop("interface")
+        vocs = TEST_VOCS_BASE
+
+        routine = Routine(
+            name="test_routine",
+            environment=env(**config),
+            vocs=vocs,
+            generator="random"
+        )
+        result = json.loads(routine.json())
+        assert result["environment"]["flag"] == 0
+
+        routine.environment.flag = 1
+        result = json.loads(routine.json())
+        assert result["environment"]["flag"] == 1
diff --git a/src/badger/tests/test_factory.py b/src/badger/tests/test_factory.py
new file mode 100644
index 00000000..f9e4ef9b
--- /dev/null
+++ b/src/badger/tests/test_factory.py
@@ -0,0 +1,39 @@
+import json
+from copy import deepcopy
+
+from xopt.generators import get_generator, get_generator_defaults
+from xopt.resources.testing import TEST_VOCS_BASE
+
+
+class TestFactory:
+    def test_generator_generation(self):
+        from badger.factory import list_generators
+
+        generator_names = list_generators()
+
+        # test serializing and loading from objects
+        for name in generator_names:
+            gen_config = get_generator_defaults(name)
+            gen_class = get_generator(name)
+
+            if name in ["mobo", "cnsga", "mggpo"]:
+                gen_config["reference_point"] = {"y1": 10.0}
+                json.dumps(gen_config)
+
+                gen_class(vocs=TEST_VOCS_BASE, **gen_config)
+            elif name in ["multi_fidelity"]:
+                test_vocs = deepcopy(TEST_VOCS_BASE)
+                test_vocs.constraints = {}
+                json.dumps(gen_config)
+
+                gen_class(vocs=test_vocs, **gen_config)
+            elif name in ["bayesian_exploration"]:
+                test_vocs = deepcopy(TEST_VOCS_BASE)
+                test_vocs.objectives = {}
+                test_vocs.observables = ["f"]
+                json.dumps(gen_config)
+
+                gen_class(vocs=test_vocs, **gen_config)
+            else:
+                json.dumps(gen_config)
+                gen_class(vocs=TEST_VOCS_BASE, **gen_config)
diff --git a/src/badger/tests/test_gui_basic.py b/src/badger/tests/test_gui_basic.py
index ebf6bf73..e03dbe5b 100644
--- a/src/badger/tests/test_gui_basic.py
+++ b/src/badger/tests/test_gui_basic.py
@@ -1,13 +1,46 @@
+import pytest
 from PyQt5.QtCore import Qt
 
 
 def test_gui_main(qtbot):
     from badger.gui.default.windows.main_window import BadgerMainWindow
+    from badger.tests.utils import fix_db_path_issue
+
+    fix_db_path_issue()
 
     window = BadgerMainWindow()
-    window.show()
     qtbot.addWidget(window)
 
     # Test new routine feature
     qtbot.mouseClick(window.home_page.btn_new, Qt.MouseButton.LeftButton)
     assert window.stacks.currentWidget().tabs.currentIndex() == 1
+
+
+def test_close_main(qtbot):
+    from badger.gui.default.windows.main_window import BadgerMainWindow
+    from badger.tests.utils import fix_db_path_issue, create_routine
+
+    fix_db_path_issue()
+
+    window = BadgerMainWindow()
+    qtbot.addWidget(window)
+
+    routine = create_routine()
+    home_page = window.home_page
+    home_page.current_routine = routine
+    home_page.run_monitor.testing = True
+    home_page.run_monitor.termination_condition = {
+        "tc_idx": 0,
+        "max_eval": 3,
+    }
+    home_page.go_run(-1)
+
+    home_page.run_monitor.start(True)
+    # Wait until the run is done
+    while home_page.run_monitor.running:
+        qtbot.wait(100)
+
+    window.close()  # this action show release the env
+    # So we expect an AttributeError here
+    with pytest.raises(AttributeError):
+        routine.environment
diff --git a/src/badger/tests/test_home_page.py b/src/badger/tests/test_home_page.py
new file mode 100644
index 00000000..41f21cfd
--- /dev/null
+++ b/src/badger/tests/test_home_page.py
@@ -0,0 +1,34 @@
+def test_home_page_run_routine(qtbot):
+    from badger.gui.default.pages.home_page import BadgerHomePage
+
+    from badger.tests.utils import (
+        create_multiobjective_routine,
+        create_routine,
+        create_routine_turbo,
+        fix_db_path_issue,
+    )
+
+    fix_db_path_issue()
+    home_page = BadgerHomePage()
+
+    # test running routines w high level interface
+    routines = [
+        create_routine(),
+        create_multiobjective_routine(),
+        create_routine_turbo(),
+    ]
+    for ele in routines:
+        home_page.current_routine = ele
+        home_page.run_monitor.testing = True
+        home_page.run_monitor.termination_condition = {
+            "tc_idx": 0,
+            "max_eval": 3,
+        }
+        home_page.go_run(-1)
+
+        # start run in a thread and wait some time for it to finish
+        home_page.run_monitor.start(True)
+        qtbot.wait(1000)
+
+        # assert we get the right result, ie. correct number of samples
+        assert len(home_page.run_monitor.routine_runner.routine.data) == 3
diff --git a/src/badger/tests/test_intf.py b/src/badger/tests/test_intf.py
index 242a8cb9..891ae9de 100644
--- a/src/badger/tests/test_intf.py
+++ b/src/badger/tests/test_intf.py
@@ -83,13 +83,13 @@ def test_recording():
 #     from coolname import generate_slug
 #     from badger.log import config_log
 #     config_log()
-#     from badger.factory import get_algo, get_env
+#     from badger.factory import get_generator, get_env
 #     from badger.utils import load_config, merge_params, config_list_to_dict
 #     from badger.core import normalize_routine, run_routine
 
 #     # Args
-#     algo = 'silly'
-#     algo_params = '{dimension: 2, max_iter: 10}'
+#     generator = 'silly'
+#     generator_params = '{dimension: 2, max_iter: 10}'
 #     env = 'silly'
 #     env_params = None
 #     config = os.path.join(mock_config_root, 'test.yaml')
@@ -100,14 +100,14 @@ def test_recording():
 #     # Get env params
 #     _, configs_env = get_env(env)
 
-#     # Get algo params
-#     _, configs_algo = get_algo(algo)
+#     # Get generator params
+#     _, configs_generator = get_generator(generator)
 
-#     # Normalize the algo and env params
+#     # Normalize the generator and env params
 #     params_env = load_config(env_params)
-#     params_algo = load_config(algo_params)
+#     params_generator = load_config(generator_params)
 #     params_env = merge_params(configs_env['params'], params_env)
-#     params_algo = merge_params(configs_algo['params'], params_algo)
+#     params_generator = merge_params(configs_generator['params'], params_generator)
 
 #     # Load routine configs
 #     configs_routine = load_config(config)
@@ -115,9 +115,9 @@ def test_recording():
 #     # Compose the routine
 #     routine = {
 #         'name': save or generate_slug(2),
-#         'algo': algo,
+#         'generator': generator,
 #         'env': env,
-#         'algo_params': params_algo,
+#         'generator_params': params_generator,
 #         'env_params': params_env,
 #         # env_vranges is an additional info for the normalization
 #         # Will be removed after the normalization
diff --git a/src/badger/tests/test_lib_basic.py b/src/badger/tests/test_lib_basic.py
index 57d66256..bc9ed523 100644
--- a/src/badger/tests/test_lib_basic.py
+++ b/src/badger/tests/test_lib_basic.py
@@ -1,14 +1,14 @@
-# def test_algo_api():
-#     from badger.factory import get_algo
+# def test_generator_api():
+#     from badger.factory import get_generator
 
-#     _, configs = get_algo('upper_confidence_bound')
+#     _, configs = get_generator('upper_confidence_bound')
 #     assert configs['name'] == 'upper_confidence_bound'
 
 
 def test_env_api():
     from badger.factory import list_env, get_env
 
-    assert len(list_env()) == 1
+    assert len(list_env()) == 2
 
     _, configs = get_env('test')
     assert configs['name'] == 'test'
@@ -27,13 +27,13 @@ def test_intf_api():
 #     from coolname import generate_slug
 #     from badger.log import config_log
 #     config_log()
-#     from badger.factory import get_algo, get_env
+#     from badger.factory import get_generator, get_env
 #     from badger.utils import load_config, merge_params, config_list_to_dict
 #     from badger.core import normalize_routine, run_routine
 
 #     # Args
-#     algo = 'silly'
-#     algo_params = '{dimension: 2, max_iter: 10}'
+#     generator = 'silly'
+#     generator_params = '{dimension: 2, max_iter: 10}'
 #     env = 'silly'
 #     env_params = None
 #     config = os.path.join(mock_config_root, 'test.yaml')
@@ -44,14 +44,14 @@ def test_intf_api():
 #     # Get env params
 #     _, configs_env = get_env(env)
 
-#     # Get algo params
-#     _, configs_algo = get_algo(algo)
+#     # Get generator params
+#     _, configs_generator = get_generator(generator)
 
-#     # Normalize the algo and env params
+#     # Normalize the generator and env params
 #     params_env = load_config(env_params)
-#     params_algo = load_config(algo_params)
+#     params_generator = load_config(generator_params)
 #     params_env = merge_params(configs_env['params'], params_env)
-#     params_algo = merge_params(configs_algo['params'], params_algo)
+#     params_generator = merge_params(configs_generator['params'], params_generator)
 
 #     # Load routine configs
 #     configs_routine = load_config(config)
@@ -59,9 +59,9 @@ def test_intf_api():
 #     # Compose the routine
 #     routine = {
 #         'name': save or generate_slug(2),
-#         'algo': algo,
+#         'generator': generator,
 #         'env': env,
-#         'algo_params': params_algo,
+#         'generator_params': params_generator,
 #         'env_params': params_env,
 #         # env_vranges is an additional info for the normalization
 #         # Will be removed after the normalization
diff --git a/src/badger/tests/test_routine.py b/src/badger/tests/test_routine.py
new file mode 100644
index 00000000..93b104a5
--- /dev/null
+++ b/src/badger/tests/test_routine.py
@@ -0,0 +1,53 @@
+import os
+import pytest
+
+
+class TestRoutine:
+    def test_routine_init(self):
+        from badger.tests.utils import create_routine
+
+        routine = create_routine()
+        routine.evaluate_data(routine.initial_points)
+
+        assert len(routine.data) == 1
+
+    def test_routine_serialization(self):
+        from badger.routine import Routine
+        from badger.tests.utils import create_routine
+
+        routine = create_routine()
+        routine.evaluate_data(routine.initial_points)
+        routine.dump("test.yaml")
+
+        lroutine = Routine.from_file("test.yaml")
+        assert lroutine.environment.variable_names ==\
+            routine.environment.variable_names
+        lroutine.evaluate_data(routine.initial_points)
+
+        print(lroutine.data)
+
+        assert len(lroutine.data) == 2
+
+    def test_routine_env_dump(self):
+        from badger.tests.utils import create_routine
+        from badger.environment import Environment
+        from badger.routine import Routine
+
+        routine = create_routine()
+        assert isinstance(routine.environment, Environment)
+
+        routine.environment.flag = 1
+        assert routine.environment.flag == 1
+
+        routine_str = routine.yaml()
+        # print(routine_str, 'yaml')
+        routine_re = Routine.from_yaml(routine_str)
+        assert routine_re.environment.flag == 1
+
+    @pytest.fixture(scope="module", autouse=True)
+    def clean_up(self):
+        yield
+        files = ["test.yaml"]
+        for f in files:
+            if os.path.exists(f):
+                os.remove(f)
diff --git a/src/badger/tests/test_routine_editor.py b/src/badger/tests/test_routine_editor.py
new file mode 100644
index 00000000..f0e33a26
--- /dev/null
+++ b/src/badger/tests/test_routine_editor.py
@@ -0,0 +1,17 @@
+def test_init(qtbot):
+    from badger.gui.default.components.routine_editor import BadgerRoutineEditor
+    BadgerRoutineEditor()
+
+
+def test_routine_set_and_save(qtbot):
+    from badger.gui.default.components.routine_editor import BadgerRoutineEditor
+    from badger.tests.utils import create_routine, fix_db_path_issue
+
+    fix_db_path_issue()
+
+    window = BadgerRoutineEditor()
+
+    routine = create_routine()
+    window.set_routine(routine)
+
+    window.save_routine()
diff --git a/src/badger/tests/test_routine_page.py b/src/badger/tests/test_routine_page.py
new file mode 100644
index 00000000..5fc7ece1
--- /dev/null
+++ b/src/badger/tests/test_routine_page.py
@@ -0,0 +1,83 @@
+import pandas as pd
+import pytest
+from PyQt5.QtCore import Qt
+
+from badger.errors import BadgerRoutineError
+from badger.tests.utils import create_routine
+
+
+def test_routine_page_init(qtbot):
+    from badger.gui.default.components.routine_page import BadgerRoutinePage
+
+    window = BadgerRoutinePage()
+
+    qtbot.addWidget(window)
+
+
+def test_routine_generation(qtbot):
+    # test if a simple routine can be created
+    from badger.gui.default.components.routine_page import BadgerRoutinePage
+    window = BadgerRoutinePage()
+    qtbot.addWidget(window)
+
+    # test without anything selected
+    with pytest.raises(BadgerRoutineError):
+        window._compose_routine()
+
+    # add generator -- still should raise error for no environment
+    qtbot.keyClicks(window.generator_box.cb, "upper_confidence_bound")
+    with pytest.raises(BadgerRoutineError):
+        window._compose_routine()
+
+    # finally add the test environment
+    qtbot.keyClicks(window.env_box.cb, "test")
+
+    # click checkbox to select vars/objectives
+    window.env_box.var_table.cellWidget(0, 0).setChecked(True)
+    assert window.env_box.var_table.export_variables() == {"x0": [-1, 1]}
+
+    window.env_box.obj_table.cellWidget(0, 0).setChecked(True)
+    assert window.env_box.obj_table.export_objectives() == {"f": "MINIMIZE"}
+
+    routine = window._compose_routine()
+    assert routine.vocs.variables == {"x0": [-1, 1]}
+    assert routine.vocs.objectives == {"f": "MINIMIZE"}
+    assert routine.initial_points.empty
+
+
+def test_initial_points(qtbot):
+    # test to make sure initial points widget works properly
+    from badger.gui.default.components.routine_page import BadgerRoutinePage
+
+    window = BadgerRoutinePage()
+    qtbot.addWidget(window)
+
+    qtbot.keyClicks(window.env_box.cb, "test")
+    qtbot.keyClicks(window.generator_box.cb, "random")
+
+    window.env_box.var_table.cellWidget(0, 0).setChecked(True)
+    window.env_box.var_table.cellWidget(1, 0).setChecked(True)
+    window.env_box.var_table.cellWidget(2, 0).setChecked(True)
+
+    assert window.env_box.init_table.horizontalHeader().count() == 3
+
+    # test routine generation with fake current values selected
+    qtbot.mouseClick(window.env_box.btn_add_curr, Qt.LeftButton)
+    routine = window._compose_routine()
+    assert routine.initial_points.to_dict() == pd.DataFrame(
+        {"x0": 0, "x1": 0, "x2": 0}, index=[0]).to_dict()
+
+
+def test_ui_update(qtbot):
+    # test to make sure initial points widget works properly
+    from badger.gui.default.components.routine_page import BadgerRoutinePage
+
+    window = BadgerRoutinePage()
+
+    # test with none
+    window.refresh_ui()
+
+    routine = create_routine()
+    window.refresh_ui(routine)
+
+    assert window.generator_box.edit.toPlainText() == "{}\n"
diff --git a/src/badger/tests/test_routine_runner.py b/src/badger/tests/test_routine_runner.py
new file mode 100644
index 00000000..247fcfb0
--- /dev/null
+++ b/src/badger/tests/test_routine_runner.py
@@ -0,0 +1,15 @@
+
+class TestRoutineRunner:
+    def test_routine_runner(self, qtbot):
+        from badger.gui.default.components.routine_runner import BadgerRoutineRunner
+        from badger.tests.utils import create_routine
+
+        routine = create_routine()
+
+        runner = BadgerRoutineRunner(routine, False)
+        runner.set_termination_condition({"tc_idx": 0, "max_eval": 2})
+
+        runner.run()
+        assert len(runner.routine.data) == 2
+
+        # TODO: check for signal emit message
diff --git a/src/badger/tests/test_run_monitor.py b/src/badger/tests/test_run_monitor.py
new file mode 100644
index 00000000..45d27ecc
--- /dev/null
+++ b/src/badger/tests/test_run_monitor.py
@@ -0,0 +1,408 @@
+import time
+from unittest.mock import patch
+
+import numpy as np
+
+from PyQt5.QtCore import QPointF, Qt, QTimer
+from PyQt5.QtGui import QMouseEvent
+from PyQt5.QtTest import QSignalSpy, QTest
+from PyQt5.QtWidgets import QMessageBox, QApplication
+
+
+def create_test_run_monitor(add_data=True):
+    from badger.gui.default.components.run_monitor import BadgerOptMonitor
+    from badger.tests.utils import create_routine, fix_db_path_issue
+
+    fix_db_path_issue()
+
+    monitor = BadgerOptMonitor()
+    monitor.testing = True
+
+    routine = create_routine()
+    if add_data:
+        routine.random_evaluate(10)
+    monitor.init_plots(routine)
+
+    if add_data:
+        assert len(routine.data) == 10
+
+    return monitor
+
+
+def test_run_monitor(qtbot):
+    from badger.gui.default.components.run_monitor import BadgerOptMonitor
+    from badger.tests.utils import create_routine, fix_db_path_issue
+
+    fix_db_path_issue()
+
+    monitor = BadgerOptMonitor()
+    monitor.testing = True
+    qtbot.addWidget(monitor)
+
+    routine = create_routine()
+
+    # test initialization - first w/o routine
+    monitor.init_plots()
+    assert monitor.btn_stop.text() == "Run"
+
+    # test initialization - then w/ routine
+    monitor.init_plots(routine)
+    assert monitor.btn_stop.text() == "Run"
+
+    # add some data
+    monitor.routine.step()
+    assert len(monitor.routine.data) == 1
+
+    # test updating plots
+    monitor.update_curves()
+    assert set(monitor.curves_variable.keys()) == {"x0", "x1", "x2", "x3"}
+    assert set(monitor.curves_objective.keys()) == {"f"}
+    assert set(monitor.curves_constraint.keys()) == {"c"}
+
+    # set up run monitor and test it
+    monitor.init_routine_runner()
+    monitor.routine_runner.set_termination_condition({"tc_idx": 0, "max_eval": 2})
+    spy = QSignalSpy(monitor.routine_runner.signals.progress)
+    assert spy.isValid()
+    QTest.mouseClick(monitor.btn_stop, Qt.MouseButton.LeftButton)
+    time.sleep(1)
+    QTest.mouseClick(monitor.btn_stop, Qt.MouseButton.LeftButton)
+
+
+def test_routine_identity(qtbot):
+    from badger.gui.default.components.run_monitor import BadgerOptMonitor
+    from badger.tests.utils import create_routine, fix_db_path_issue
+
+    fix_db_path_issue()
+
+    monitor = BadgerOptMonitor()
+    qtbot.addWidget(monitor)
+
+    routine = create_routine()
+
+    # Feed in the sample routine
+    monitor.routine = routine
+    monitor.init_routine_runner()
+
+    assert monitor.routine_runner.routine == monitor.routine
+
+
+def test_plotting(qtbot):
+    monitor = create_test_run_monitor()
+    monitor.update_curves()
+
+    monitor.plot_x_axis = 1
+    monitor.update_curves()
+
+    monitor.plot_x_axis = 0
+    monitor.x_plot_relative = 1
+    monitor.update_curves()
+
+    monitor.plot_x_axis = 1
+    monitor.x_plot_relative = 1
+    monitor.x_plot_y_axis = 1
+    monitor.update_curves()
+
+
+def test_click_graph(qtbot, mocker):
+    monitor = create_test_run_monitor()
+    sig_inspect_spy = QSignalSpy(monitor.sig_inspect)
+    monitor.plot_x_axis = True
+
+    mock_event = mocker.MagicMock(spec=QMouseEvent)
+    mock_event._scenePos = QPointF(350, 240)
+
+    orginal_value = monitor.inspector_variable.value()
+    monitor.on_mouse_click(mock_event)
+    new_variable_value = monitor.inspector_variable.value()
+
+    assert new_variable_value != orginal_value
+    assert len(sig_inspect_spy) == 1
+
+    # TODO: make asserts for other changes when the graph is clicked on by the user.
+
+
+def test_x_axis_specification(qtbot, mocker):
+    # check iteration/time drop down menu
+    monitor = create_test_run_monitor()
+
+    # read time stamp
+    time_value = monitor.inspector_variable.value()
+
+    # set inspector line index 1
+    monitor.inspector_variable.setValue(1)
+
+    # Iteration selected
+    monitor.cb_plot_x.setCurrentIndex(0)
+
+    # Test label setting
+    plot_var_axis = monitor.plot_var.getAxis("bottom")
+    assert plot_var_axis.label.toPlainText().strip() == "iterations"
+
+    plot_obj_axis = monitor.plot_obj.getAxis("bottom")
+    assert plot_obj_axis.label.toPlainText().strip() == "iterations"
+
+    if monitor.vocs.constraint_names:
+        plot_con_axis = monitor.plot_con.getAxis("bottom")
+        assert plot_con_axis.label.toPlainText().strip() == "iterations"
+
+    assert isinstance(monitor.inspector_objective.value(), int)
+    assert isinstance(monitor.inspector_variable.value(), int)
+    if monitor.vocs.constraint_names:
+        assert isinstance(monitor.inspector_constraint.value(), int)
+
+    # Time selected
+    monitor.cb_plot_x.setCurrentIndex(1)
+
+    # Test label setting
+    plot_var_axis_time = monitor.plot_var.getAxis("bottom")
+    assert plot_var_axis_time.label.toPlainText().strip() == "time (s)"
+
+    plot_obj_axis_time = monitor.plot_obj.getAxis("bottom")
+    assert plot_obj_axis_time.label.toPlainText().strip() == "time (s)"
+
+    if monitor.vocs.constraint_names:
+        plot_con_axis_time = monitor.plot_con.getAxis("bottom")
+        assert plot_con_axis_time.label.toPlainText().strip() == "time (s)"
+
+    mock_event = mocker.MagicMock(spec=QMouseEvent)
+    mock_event._scenePos = QPointF(350, 240)
+
+    monitor.on_mouse_click(mock_event)
+
+    # Check type of value
+    assert isinstance(monitor.inspector_objective.value(), float)
+    assert isinstance(monitor.inspector_variable.value(), float)
+    if monitor.vocs.constraint_names:
+        assert isinstance(monitor.inspector_constraint.value(), float)
+
+    # Switch between time and iterations and see if index changes
+    current_index = monitor.inspector_variable.value()
+
+    monitor.cb_plot_x.setCurrentIndex(0)
+    assert current_index != monitor.inspector_variable.value()
+
+    monitor.cb_plot_x.setCurrentIndex(1)
+    assert current_index == monitor.inspector_variable.value()
+
+
+def test_y_axis_specification(qtbot):
+    monitor = create_test_run_monitor(add_data=False)
+    monitor.termination_condition = {
+        "tc_idx": 0,
+        "max_eval": 10,
+    }
+    monitor.start(True)
+
+    # Wait until the run is done
+    while monitor.running:
+        qtbot.wait(100)
+
+    select_x_plot_y_axis_spy = QSignalSpy(monitor.cb_plot_y.currentIndexChanged)
+    index = monitor.inspector_variable.value()
+
+    monitor.check_relative.setChecked(False)
+
+    # check raw - non relative
+    monitor.cb_plot_y.setCurrentIndex(0)
+    assert len(select_x_plot_y_axis_spy) == 0  # since 0 is the default value
+    raw_value = monitor.curves_variable["x0"].getData()[1][index]
+    assert raw_value == 0.5
+
+    # relative
+    monitor.check_relative.setChecked(True)
+
+    # check non normalized relative.
+    relative_value = monitor.curves_variable["x0"].getData()[1][index]
+    assert relative_value == 0.0
+
+    # normalized relative
+    monitor.cb_plot_y.setCurrentIndex(1)
+    assert len(select_x_plot_y_axis_spy) == 1
+
+    normalized_relative_value = monitor.curves_variable["x0"].getData()[1][index]
+    assert normalized_relative_value == 0.0
+
+    # raw normalized
+    monitor.check_relative.setChecked(False)
+
+    normalized_raw_value = monitor.curves_variable["x0"].getData()[1][index]
+    assert normalized_raw_value == 0.75
+
+
+def test_pause_play(qtbot):
+    monitor = create_test_run_monitor(add_data=False)
+
+    monitor.termination_condition = {
+        "tc_idx": 0,
+        "max_eval": 10,
+    }
+    spy = QSignalSpy(monitor.sig_pause)
+
+    monitor.start(True)
+    qtbot.wait(500)
+
+    qtbot.mouseClick(monitor.btn_ctrl, Qt.MouseButton.LeftButton)
+    assert len(spy) == 1
+
+    qtbot.wait(500)
+
+    qtbot.mouseClick(monitor.btn_ctrl, Qt.MouseButton.LeftButton)
+    assert len(spy) == 2
+
+    while monitor.running:
+        qtbot.wait(100)
+
+
+def test_jump_to_optimum(qtbot):
+    monitor = create_test_run_monitor()
+    spy = QSignalSpy(monitor.btn_opt.clicked)
+    qtbot.mouseClick(monitor.btn_opt, Qt.MouseButton.LeftButton)
+
+    qtbot.wait(500)
+
+    data = monitor.routine.sorted_data
+
+    max_value = data["f"].max()
+    optimal_value_idx = monitor.inspector_variable.value()
+    optimal_value = monitor.routine.sorted_data["f"][optimal_value_idx]
+
+    # Check if signal is triggered
+    assert len(spy) == 1
+
+    # Check if it is going to be the optimal solution
+    assert max_value == optimal_value
+
+
+def test_reset_environment(qtbot):
+    from badger.tests.utils import get_current_vars, get_vars_in_row
+
+    # check if reset button click signal is trigged and if state is same as original state after click
+    monitor = create_test_run_monitor(add_data=False)
+    init_vars = get_current_vars(monitor.routine)
+
+    monitor.termination_condition = {
+        "tc_idx": 0,
+        "max_eval": 10,
+    }
+    monitor.start(True)
+
+    # Wait until the run is done
+    while monitor.running:
+        qtbot.wait(100)
+
+    assert len(monitor.routine.data) == 10
+
+    # Check if current env vars matches the last solution in data
+    last_vars = get_vars_in_row(monitor.routine, idx=-1)
+    curr_vars = get_current_vars(monitor.routine)
+    assert np.all(curr_vars == last_vars)
+
+    # Reset env and confirm
+    spy = QSignalSpy(monitor.btn_reset.clicked)
+
+    with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.Yes):
+        with patch("PyQt5.QtWidgets.QMessageBox.information") as mock_info:
+            qtbot.mouseClick(monitor.btn_reset, Qt.MouseButton.LeftButton)
+            mock_info.assert_called_once()
+
+    assert len(spy) == 1
+
+    # Check if the env has been reset
+    curr_vars = get_current_vars(monitor.routine)
+    assert np.all(curr_vars == init_vars)
+
+
+def test_dial_in_solution(qtbot):
+    from badger.tests.utils import get_current_vars, get_vars_in_row
+
+    monitor = create_test_run_monitor()
+
+    # Check if current env vars matches the last solution in data
+    last_vars = get_vars_in_row(monitor.routine, idx=-1)
+    curr_vars = get_current_vars(monitor.routine)
+    assert np.all(curr_vars == last_vars)
+
+    # Dial in the solution at the inspector line (should be the first solution)
+    current_x_view_range = monitor.plot_var.getViewBox().viewRange()[0]
+
+    spy = QSignalSpy(monitor.btn_set.clicked)
+    with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.Yes):
+        qtbot.mouseClick(monitor.btn_set, Qt.MouseButton.LeftButton)
+    assert len(spy) == 1
+
+    new_x_view_range = monitor.plot_var.getViewBox().viewRange()[0]
+
+    assert new_x_view_range != current_x_view_range
+
+    # Test if the solution has been dialed in
+    first_vars = get_vars_in_row(monitor.routine, idx=0)
+    curr_vars = get_current_vars(monitor.routine)
+    assert np.all(curr_vars == first_vars)
+
+    monitor.plot_x_axis = False
+
+    with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.Yes):
+        qtbot.mouseClick(monitor.btn_set, Qt.MouseButton.LeftButton)
+
+    not_time_x_view_range = monitor.plot_var.getViewBox().viewRange()[0]
+
+    assert new_x_view_range != not_time_x_view_range
+
+
+def test_run_until(qtbot):
+    monitor = create_test_run_monitor(add_data=False)
+
+    def handle_dialog():
+        while monitor.tc_dialog is None:
+            QApplication.processEvents()
+
+        # Set max evaluation to 5, then run the optimization
+        monitor.tc_dialog.sb_max_eval.setValue(5)
+        qtbot.mouseClick(monitor.tc_dialog.btn_run, Qt.MouseButton.LeftButton)
+
+    QTimer.singleShot(0, handle_dialog)
+    monitor.run_until_action.trigger()
+
+    # Wait until the run is done
+    while monitor.running:
+        qtbot.wait(100)
+
+    assert len(monitor.routine.data) == 5
+
+
+def test_add_extensions(qtbot):
+    from badger.gui.default.components.analysis_extensions import ParetoFrontViewer
+    from badger.gui.default.components.run_monitor import BadgerOptMonitor
+    from badger.tests.utils import create_routine
+
+    routine = create_routine()
+    routine.vocs.objectives = {"f1": "MINIMIZE", "f2": "MAXIMIZE"}
+
+    # test w/o using qtbot
+    monitor = BadgerOptMonitor()
+    monitor.routine = routine
+    qtbot.addWidget(monitor)
+
+    monitor.open_extensions_palette()
+    monitor.extensions_palette.add_pf_viewer()
+
+    assert isinstance(monitor.active_extensions[0], ParetoFrontViewer)
+
+    # TODO: logic has been changed, if ext is not applicable it won't be
+    # added to the extensions palette. In order to test we need to feed in
+    # a MO run here
+
+    # test opening and closing windows
+    # monitor = BadgerOptMonitor()
+    # qtbot.addWidget(monitor)
+
+    # qtbot.mouseClick(monitor.btn_open_extensions_palette, Qt.LeftButton)
+    # qtbot.mouseClick(monitor.extensions_palette.btn_data_viewer, Qt.LeftButton)
+    # assert isinstance(monitor.active_extensions[0], ParetoFrontViewer)
+    # assert len(monitor.active_extensions) == 1
+
+    # test closing window -- should remove element from active extensions
+    # monitor.active_extensions[0].close()
+    # assert len(monitor.active_extensions) == 0
+    # assert monitor.extensions_palette.n_active_extensions == 0
diff --git a/src/badger/tests/test_turbo_run.py b/src/badger/tests/test_turbo_run.py
deleted file mode 100644
index c05bce4b..00000000
--- a/src/badger/tests/test_turbo_run.py
+++ /dev/null
@@ -1,198 +0,0 @@
-import copy
-import pytest
-import pandas as pd
-from typing import Type
-from xopt.generators import get_generator
-from badger.utils import merge_params, ParetoFront
-from badger.core import (
-    run_routine_xopt,
-    Routine,
-    instantiate_env,
-)
-from badger.errors import BadgerRunTerminatedError
-
-
-class TestTuRBORun:
-    """
-    This class is to provide unit test coverage for the core.py file.
-    """
-
-    @pytest.fixture(autouse=True, scope="function")
-    def test_core_setup(self) -> None:
-        self.count = 0
-        self.points_eval = None
-        self.candidates = None
-        self.points_eval_list = []
-        self.candidates_list = []
-        self.states = None
-
-        data = {"x0": [0.5], "x1": [0.5], "x2": [0.5], "x3": [0.5]}
-        data_eval_target = {
-            "x0": [0.5],
-            "x1": [0.5],
-            "x2": [0.5],
-            "x3": [0.5],
-            "f": [1.0],
-        }
-
-        self.points = pd.DataFrame(data)
-
-        self.points_eval_target = pd.DataFrame(data_eval_target)
-
-    def mock_routine(self) -> Type[Routine]:
-        """
-        A method that creates a Routine class object
-        filled with sample data for testing purposes.
-        """
-        from badger.factory import get_env
-
-        test_routine = {
-            "name": "routine-for-core-test",
-            "algo": "upper_confidence_bound",
-            "env": "test",
-            "algo_params": {
-                "model": None,
-                "turbo_controller": "optimize",
-                "use_cuda": False,
-                "gp_constructor": {
-                    "name": "standard",
-                    "use_low_noise_prior": True,
-                    "covar_modules": {},
-                    "mean_modules": {},
-                    "trainable_mean_keys": [],
-                },
-                "numerical_optimizer": {
-                    "name": "LBFGS",
-                    "n_restarts": 20,
-                    "max_iter": 2000,
-                },
-                "max_travel_distances": None,
-                "fixed_features": None,
-                "n_candidates": 1,
-                "n_monte_carlo_samples": 128,
-                "beta": 2.0,
-                "start_from_current": True,
-            },
-            "env_params": {},
-            "config": {
-                "variables": [
-                    {"x0": [-1, 1]},
-                    {"x1": [-1, 1]},
-                    {"x2": [-1, 1]},
-                    {"x3": [-1, 1]},
-                ],
-                "objectives": [{"f": "MAXIMIZE"}],
-                "constraints": None,
-                "states": None,
-                "domain_scaling": None,
-                "tags": None,
-                "init_points": {
-                    "x0": [0.5],
-                    "x1": [0.5],
-                    "x2": [0.5],
-                    "x3": [0.5],
-                },
-            },
-        }
-
-        # Initialize routine
-        Environment, configs_env = get_env(test_routine["env"])
-        _configs_env = merge_params(
-            configs_env, {"params": test_routine["env_params"]}
-        )
-        environment = instantiate_env(Environment, _configs_env)
-
-        variables = {
-            key: value
-            for dictionary in test_routine["config"]["variables"]
-            for key, value in dictionary.items()
-        }
-        objectives = {
-            key: value
-            for dictionary in test_routine["config"]["objectives"]
-            for key, value in dictionary.items()
-        }
-        vocs = {
-            "variables": variables,
-            "objectives": objectives,
-        }
-
-        generator_class = get_generator(test_routine["algo"])
-
-        del test_routine["algo_params"]["start_from_current"]
-        del test_routine["algo_params"]["n_candidates"]
-        del test_routine["algo_params"]["fixed_features"]
-
-        test_routine_copy = copy.deepcopy(test_routine["algo_params"])
-
-        generator = generator_class(vocs=vocs, **test_routine_copy)
-
-        initial_points = test_routine["config"]["init_points"]
-        initial_points = pd.DataFrame.from_dict(initial_points)
-
-        test_routine_xopt = Routine(
-            environment=environment,
-            generator=generator,
-            initial_points=initial_points,
-        )
-
-        return test_routine_xopt
-
-    def mock_active_callback(self) -> int:
-        """
-        A mock active callback method to test
-        if active callback is functioning properly.
-        """
-        if self.count >= 5:
-            return 2
-        else:
-            return 0
-
-    def mock_generate_callback(self, candidates: pd.DataFrame) -> None:
-        """
-        A mock generate callback method to test
-        if generate callback is functioning properly.
-        """
-        self.candidates_list.append(candidates)
-
-    def mock_evaluate_callback(self, points_eval: pd.DataFrame) -> None:
-        """
-        A mock evaluate callback method to test
-        if evaluate callback is functioning properly.
-        """
-        self.points_eval_list.append(points_eval)
-        self.count += 1
-
-    def mock_pf_callback(self, pf: Type[ParetoFront]) -> None:
-        """
-        A mock pareto callback method to test
-        if pareto callback is functioning properly.
-        """
-        self.pf = pf
-
-    def mock_states_callback(self, states: dict) -> None:
-        """
-        A mock states callback method to test
-        if states callback is functioning properly.
-        """
-        self.states = states
-
-    def test_TuRBO_run(self) -> None:
-        """
-        A unit test to ensure TuRBO can run in Badger.
-        """
-        routine = self.mock_routine()
-
-        with pytest.raises(BadgerRunTerminatedError):
-            run_routine_xopt(
-                routine,
-                self.mock_active_callback,
-                self.mock_generate_callback,
-                self.mock_evaluate_callback,
-                self.mock_pf_callback,
-                self.mock_states_callback,
-                dump_file_callback=None,
-            )
-
-        assert len(self.candidates_list) == self.count - 1
-        assert len(self.points_eval_list) == self.count
diff --git a/src/badger/tests/utils.py b/src/badger/tests/utils.py
new file mode 100644
index 00000000..801f52d4
--- /dev/null
+++ b/src/badger/tests/utils.py
@@ -0,0 +1,147 @@
+import os
+import pandas as pd
+from xopt import VOCS
+from xopt.generators.bayesian import UpperConfidenceBoundGenerator
+from xopt.generators import RandomGenerator
+
+
+def create_routine():
+    from badger.routine import Routine
+
+    test_routine = {
+        "name": "routine-for-core-test",
+        "generator": "random",
+        "env": "test",
+        "generator_params": {},
+        "env_params": {},
+        "vocs": {
+            "variables": {
+                "x0": [-1, 1],
+                "x1": [-1, 1],
+                "x2": [-1, 1],
+                "x3": [-1, 1]
+            },
+            "objectives": {"f": "MAXIMIZE"},
+            "constraints": {"c": ["GREATER_THAN", 0]}
+        },
+        "init_points": {
+            "x0": [0.5],
+            "x1": [0.5],
+            "x2": [0.5],
+            "x3": [0.5]
+        },
+    }
+
+    vocs = VOCS(**test_routine["vocs"])
+
+    generator = RandomGenerator(vocs=vocs)
+
+    return Routine(
+        name="test",
+        vocs=vocs,
+        generator=generator,
+        environment={"name": "test"},
+        initial_points=pd.DataFrame(test_routine["init_points"])
+    )
+
+
+def create_multiobjective_routine():
+    from badger.routine import Routine
+
+    test_routine = {
+        "name": "routine-for-core-test",
+        "generator": "random",
+        "generator_params": {},
+        "env_params": {},
+        "vocs": {
+            "variables": {
+                "x0": [-1, 1],
+                "x1": [-1, 1],
+                "x2": [-1, 1],
+                "x3": [-1, 1]
+            },
+            "objectives": {"f1": "MAXIMIZE", "f2": "MINIMIZE"},
+            "constraints": {}
+        },
+        "init_points": {"x0": [0.5], "x1": [0.5], "x2": [0.5], "x3": [0.5]},
+    }
+
+    vocs = VOCS(**test_routine["vocs"])
+
+    generator = RandomGenerator(vocs=vocs)
+
+    return Routine(
+        name="test",
+        vocs=vocs,
+        generator=generator,
+        environment={"name": "multiobjective_test"},
+        initial_points=pd.DataFrame(test_routine["init_points"])
+    )
+
+
+def create_routine_turbo():
+    from badger.routine import Routine
+
+    test_routine = {
+        "name": "routine-for-turbo-test",
+        "generator": "upper_confidence_bound",
+        "env": "test",
+        "generator_params": {
+            "turbo_controller": "optimize",
+            "gp_constructor": {
+                "name": "standard",
+                "use_low_noise_prior": True,
+            },
+            "beta": 2.0,
+        },
+        "env_params": {},
+        "config": {
+            "variables": {
+                "x0": [-1, 1],
+                "x1": [-1, 1],
+                "x2": [-1, 1],
+                "x3": [-1, 1]
+            },
+            "objectives": {"f": "MAXIMIZE"},
+            "init_points": {
+                "x0": [0.5],
+                "x1": [0.5],
+                "x2": [0.5],
+                "x3": [0.5]
+            },
+        },
+    }
+
+    vocs = VOCS(
+        variables=test_routine["config"]["variables"],
+        objectives=test_routine["config"]["objectives"],
+    )
+
+    generator = UpperConfidenceBoundGenerator(
+        vocs=vocs, **test_routine["generator_params"])
+
+    return Routine(
+        name="test-turbo",
+        vocs=vocs,
+        generator=generator,
+        environment={"name": "test"},
+        initial_points=pd.DataFrame(test_routine["config"]["init_points"])
+    )
+
+
+def fix_db_path_issue():
+    from badger.db import BADGER_DB_ROOT
+
+    os.makedirs(BADGER_DB_ROOT, exist_ok=True)
+
+
+def get_current_vars(routine):
+    var_names = routine.vocs.variable_names
+    var_dict = routine.environment._get_variables(var_names)
+
+    return list(var_dict.values())
+
+
+def get_vars_in_row(routine, idx=0):
+    var_names = routine.vocs.variable_names
+    return routine.data.iloc[idx][var_names].to_numpy()