diff --git a/README.md b/README.md
index 49c7c8b3c85f91445e50bf9b241dd809f4d2c571..321815f75c666edd17729d5ffeb67096a1801c7b 100644
--- a/README.md
+++ b/README.md
@@ -1,94 +1,34 @@
-# lifescale_gui
+# lifescale_utils
 Data analysis tools for lifescale with GUI.
 
-# Installation and setup
-* **1.  Create virtual environment (venv)**
-  * `python3 -m venv env`
-* **2. Activate virtual environment**
-  * `source env/bin/activate`
-* **3. Clone git repository to local machine**
-  * `git clone git@gitlab.com:hellerdev/lifescale_gui.git`
-  * `cd lifescale_gui`
-* **4. Install required python packages using pip**
-  * `pip install -r requirements.txt`
+# Command line programms:
 
-  
-## Installation issues on Ubuntu (20.04):
-After just installing PyQt5 with pip3 the following error occurred when trying to actually run a PyQt GUI: qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found.
-This issue was resolved by installing the QT dev tools (Designer, etc.): 
-sudo apt-get install qttools5-dev-tools
+## ls2csv
+The program *ls2csv* reads the content of the xlsm files written by lifescale units, parses the data and writes thems to three csv 
+fieles:
+  * Masses_Vibrio_[run-name].csv: Contains the data series from the sheet AcquisitionIntervals.
+  * SampleMetadata_[run-name].csv: Data from the sheet PanelData.
+  * Summary_[run-name].csv: Contains the data from the sheet IntervalAnalysis.
 
-# Test installation with setuptools 
-With command line interface.
+### Usage:
+  * Conversion: `ls2csv -i [path and nale of xlsm file] -o [outpur directory]`
+  * Help: `ls2csv -h`
 
-* **1. Configure setup.py**
-  * Define entry points (*console_scripts*)
-* **2. Activate virtual environment**
-  * e.g. `source env/bin/activate`
-* **3. Run setup.py**
-  * `python3 setup.py develop`
 
-## Using make
-`python3 setup.py develop`
+# License and copyright
 
-# Run application on Windows and create a stand-alone Windows executable file:
-TODO
- 
-# Comments on requirements.txt file:
-* Two entries can be deleted:
-  * -e git+git@gitlab.com:Heller182/grav.git@fe528c0769502e84a06be67a742032cacfd386df#egg=gravtools
-  * pkg-resources==0.0.0 (created due a bug when using Linux, see: https://stackoverflow.com/questions/39577984/what-is-pkg-resources-0-0-0-in-output-of-pip-freeze-command)
+Copyright (C) 2022  Andreas Hellerschmied (<heller182@gmx.at>)
 
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
 
-# Create HTML documentation with sphinx:
-Run make in the gravtools/doc directory: 
-* `>>>make html_doc`
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
 
-# Guidelines and conventions
-
-## Code style:
-* Respect the PEP conventions on python coding!
-  * PEP 8 -- Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008/
-* The maximum line length is 120 characters
-* Use **type hints**: https://www.python.org/dev/peps/pep-0484/
-* Use docstrings according to the numpy standard: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard
-  * They are useful to generate the documentation automatically
-  * Example: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html
-* Comment code, if necessary!
-* Use English language for the code, docstrings and comments
-  * German is allowed for user interfaces (GUI, command line), although English is preferred
-
-## Documentation and docstring style
-* The API reference is created with sphinx (https://www.sphinx-doc.org/).
-* Docstrings have to follow the numpy standard, see: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard
-  * Examples: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html
-* Package documentation via docstring in __ini__.py files
-* Module documentation via docstring at first lines of py-file
-* Documentation of classes, class methods and functions via docstrings
-  
-## Command line interface and executable scripts
-* The command line interface is realized via entry points (console_scripts) in setuptools (python packaging tool)
-  * Input arguments are handled with argparse
-  * The code is located in the command_line module (gravtools/command_line.py)
-* Executable scripts are located in gravtools/scripts
-
-## Dependancies
-* Required python packages are listed in requirements.txt
-  * created with `>>>pip freeze > requirements.txt`
-  
-## Version control with GIT
-* Gitlab repository: https://gitlab.com/Heller182/grav
-* Branching model:
-  * **master** branch: Current release version
-  * **develop** branch: Current working version. 
-    * All team members merge their feature branches into develop (merge request via gitlab)
-    * Make sure that the develop branch contains a fully functional version of the code!
-  * **feature** branches: Branches of develop for the implementation of new features and other changes.
-    * Code changes only in feature branches!
-    * Naming convention: feature_<description of change/feature>, e.g. feature_new_tide_model
-* Use gitignore files to prevent any data files (except example files), IDE control files, compiled python code, etc. from being stored in the GIT repository
-  * Generally rule: Ignore everything in a directory and define explicit exceptions!
-    
-## Packaging and distribution
-* With setuptools 
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
diff --git a/lifescale/__init__.py b/lifescale/__init__.py
index d0606497d4371e2c9983b4bc823072ad7ebc7069..9e0448d387540c298fffc572385958227863965d 100644
--- a/lifescale/__init__.py
+++ b/lifescale/__init__.py
@@ -1,4 +1,4 @@
-"""LifeSclae GUI is a utility program for handling data output.
+"""LifeSclae utils is a utility program for handling data output.
 
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
     Andreas Hellerschmied (heller182@gmx.at)
 """
 
-__version__ = '0.0.1'
+__version__ = '0.0.2'
 __author__ = 'Andreas Hellerschmied'
 __git_repo__ = 'tba'
 __email__ = 'heller182@gmx.at'
diff --git a/lifescale/mass_peak_caller/__init__.py b/lifescale/command_line/__init__.py
similarity index 100%
rename from lifescale/mass_peak_caller/__init__.py
rename to lifescale/command_line/__init__.py
diff --git a/lifescale/command_line/command_line.py b/lifescale/command_line/command_line.py
new file mode 100644
index 0000000000000000000000000000000000000000..212762d19b1a61cca49eb7db660d71fe6afd0f21
--- /dev/null
+++ b/lifescale/command_line/command_line.py
@@ -0,0 +1,49 @@
+""" Command line interface of lifescale utils
+
+Copyright (C) 2022  Andreas Hellerschmied <heller182@gmx.at>
+"""
+
+from lifescale.scripts.ls2csv import ls2csv as ls2csv_main
+import argparse
+import os
+
+
+def is_file(filename):
+    """Check, whether the input string is the path to an existing file."""
+    if os.path.isfile(filename):
+        return filename
+    raise argparse.ArgumentTypeError("'{}' is not a valid file.".format(filename))
+
+
+def is_dir(pathname):
+    """Check, whether the input string is a valid and existing filepath."""
+    if os.path.exists(pathname):
+        return pathname
+    raise argparse.ArgumentTypeError("'{}' is not a valid directory.".format(pathname))
+
+
+def ls2csv():
+    """Command line interface including argument parser for the lifescale2csv converter."""
+    parser = argparse.ArgumentParser(prog="ls2csv",
+                                     description="Covnersion from lifescale xlsm output to csv files",
+                                     epilog="The ls2csv converter loads and parses xslm files created by the lifescale "
+                                            "unit. It writes several csv files to the output directory that contain "
+                                            "extraced data from the input xlsm file in an easily readable way.",
+                                     formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    parser.add_argument("-i", "--input-xlsm", type=is_file, required=True, help="Path and name of the input xlsm file created by "
+                                                                 "lifescale.")
+    parser.add_argument("-o", "--out-dir", type=is_dir, required=True, help="Output directory for the CSV files.")
+    parser.add_argument("-nv", "--not-verbose", required=False, help="Disable command line status messages.",
+                        action='store_true')
+    # parser.add_argument("--out-dir", type=is_dir, required=False,
+    #                     help="path to output directory", default=OUT_PATH)
+    args = parser.parse_args()
+    verbose = not args.not_verbose
+
+    return ls2csv_main(xlsm_filename=args.input_xlsm, oputput_dir=args.out_dir, verbose=verbose)
+
+
+if __name__ == '__main__':
+    ls2csv()
+
+
diff --git a/lifescale/mass_peak_caller/configure_peakcaller.py b/lifescale/mass_peak_caller/configure_peakcaller.py
deleted file mode 100644
index 5268d8c30172e67109ba4ec77dda84fab8bed97e..0000000000000000000000000000000000000000
--- a/lifescale/mass_peak_caller/configure_peakcaller.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import os
-import platform
-import json
-
-DEFAULT_CONFIG = {
-    "mass_transformation": 0.00574,
-    "mass_cutoff": 20,
-    "peak_width_cutoff": 5,
-    "peak_distance_cutoff": 5,
-    "raw_data_folder": "~/research/lifescale_raw_data_test/development_raw_data_folder"
-}
-
-LINUX_PATH = "./dev_config.json"
-WINDOWS_PATH = r"C:\Users\LifeScale\Documents\peak_caller_config\peak_caller_config.json"
-
-def load_config():
-    if platform.system() == "Linux":
-        try:
-            with open(LINUX_PATH, "r") as f:
-                config = json.load(f)
-            return config, None
-        except FileNotFoundError as e:
-            config = DEFAULT_CONFIG
-            with open(LINUX_PATH, "w") as f:
-                json.dump(config, f)
-            return config, LINUX_PATH
-    elif platform.system() == "Windows":
-        try:
-            with open(WINDOWS_PATH, "r") as f:
-                config = json.load(f)
-            return config, None
-        except FileNotFoundError as e:
-            config = DEFAULT_CONFIG
-            with open(WINDOWS_PATH, "w") as f:
-                json.dump(config, f)
-            return config, WINDOWS_PATH
-
-def configure_peakcaller(raw_data_folder, mass_transformation, mass_cutoff, peak_width_cutoff, peak_distance_cutoff, config, command):
-    print(locals())
-    new_config = {k:v for k,v in locals().items() if k != "config" and k != "command" and v is not None}
-    print(new_config)
-    old_config = locals()["config"]
-    merged_config = {k:new_config[k] if k in new_config else old_config[k] for k in old_config}
-    if platform.system() == "Linux":
-        with open(LINUX_PATH, "w") as f:
-            json.dump(merged_config, f)
-    elif platform.system() == "Windows":
-        with open(WINDOWS_PATH, "w") as f:
-            json.dump(merged_config, f)
-    return merged_config
diff --git a/lifescale/mass_peak_caller/dev_config.json b/lifescale/mass_peak_caller/dev_config.json
deleted file mode 100644
index 129888342d7db45b2f7919d3acdb0d7697625476..0000000000000000000000000000000000000000
--- a/lifescale/mass_peak_caller/dev_config.json
+++ /dev/null
@@ -1 +0,0 @@
-{"mass_transformation": 0.00574, "mass_cutoff": 20.0, "peak_width_cutoff": 5.0, "peak_distance_cutoff": 5.0, "raw_data_folder": "/home/heller/pyProjects/gooey_lifescale/LSdata/raw_data"}
\ No newline at end of file
diff --git a/lifescale/mass_peak_caller/naive_peaks.py b/lifescale/mass_peak_caller/naive_peaks.py
deleted file mode 100644
index fd51974faf6ab8bd5bd33082fb7d3c7b21cebbd3..0000000000000000000000000000000000000000
--- a/lifescale/mass_peak_caller/naive_peaks.py
+++ /dev/null
@@ -1,92 +0,0 @@
-""" GUI application for processing LifeScale data.
-
-copyright 2019 Joseph Elsherbini
-all rights reserved
-"""
-
-import os
-import struct
-import json
-import re
-import datetime
-from itertools import chain
-from operator import itemgetter
-import numpy as np
-import pandas as pd
-import scipy.signal
-
-NOW = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
-
-def list_experiments(config):
-    raw_data_files = [f for f in os.listdir(os.path.expanduser(config["raw_data_folder"])) if re.search(r"(.+)_(\d{6})_(\d{6})", f) and os.path.splitext(f)[1] == ""]
-    unique_experiments = sorted(sorted(list(set([re.search(r"(.+)_(\d{6})_(\d{6})", f).groups() for f in raw_data_files])),
-                         key=itemgetter(2), reverse=True), key=itemgetter(1), reverse=True)
-    return (["{} {}".format(e[0], get_date_time(e[1], e[2])) for e in unique_experiments], ["_".join(e) for e in unique_experiments])
-
-def get_date_time(date, time):
-    fmt_string = "%m/%d/%Y %H:%M:%S"
-    return datetime.datetime(2000+int(date[0:2]), int(date[2:4]), int(date[4:6]), int(time[0:2]), int(time[2:4]), int(time[4:6])).strftime(fmt_string)
-
-
-def call_peaks(experiment, output_folder, metadata_file, config, command):
-    update_now()
-    all_experiments= list_experiments(config)
-    exp_name = [e[1] for e in zip(all_experiments[0], all_experiments[1]) if e[0] == experiment][0]
-    exp_files = [os.path.join(os.path.expanduser(config["raw_data_folder"]), f) for f in os.listdir(os.path.expanduser(config["raw_data_folder"])) if exp_name in f and os.path.splitext(f)[1] == ""]
-    print(exp_name, exp_files)
-    peaks = write_peaks(exp_name, exp_files, output_folder, metadata_file, config)
-    write_summary(exp_name, peaks, output_folder)
-    # TODO write_plots(exp_name, peaks, output_folder, config)
-    write_config(exp_name, output_folder, config)
-    return config
-
-def update_now():
-    global NOW
-    NOW = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
-
-def parse_metadata(metadata_file):
-    return pd.read_csv(metadata_file)[["Id", "Well"]]
-
-def load_raw_data(exp_name, exp_files):
-    for f_path in exp_files:
-        m = re.search(r"(.+)_(\d{6})_(\d{6})_c(\d+)_v(\d+)", f_path)
-        exp_date, exp_time, exp_cycle, exp_measurement = m.group(2,3,4,5)
-        print(exp_name, exp_date, exp_time, exp_cycle, exp_measurement)
-        n_datapoints = int(os.path.getsize(f_path) / 8)
-        with open(f_path, "rb") as f:
-            content = f.read()
-            a = np.array(struct.unpack("d"*n_datapoints, content))[10:]
-        yield dict(zip(["exp_name", "exp_date", "exp_time", "exp_cycle", "exp_measurement", "data_array"],
-                       [exp_name, exp_date, exp_time, exp_cycle, exp_measurement, a]))
-
-def generate_peaks(measurement, config):
-    filtered_signal = scipy.signal.savgol_filter(measurement["data_array"], window_length=5, polyorder=3)
-    peaks, _ = scipy.signal.find_peaks(-filtered_signal, width=config["peak_width_cutoff"], prominence=config["mass_cutoff"]*config["mass_transformation"], distance=config["peak_distance_cutoff"])
-    masses = scipy.signal.peak_prominences(-filtered_signal, peaks)[0]*(1/config["mass_transformation"])
-    for peak, mass in zip(peaks, masses):
-        yield dict(zip(["exp_name", "exp_date", "exp_time", "exp_cycle", "exp_measurement", "event_index","event_mass"],
-                       [measurement["exp_name"], measurement["exp_date"],measurement["exp_time"],measurement["exp_cycle"],measurement["exp_measurement"], peak, mass]))
-
-def write_peaks(exp_name, exp_files, output_folder, metadata_file, config):
-    peaks = pd.DataFrame(chain.from_iterable([generate_peaks(measurement, config) for measurement in load_raw_data(exp_name, exp_files)]))
-    if metadata_file:
-        metadata = parse_metadata(metadata_file)
-        peaks = peaks.astype({'exp_measurement':'int32'}).merge(metadata.astype({'Id':'int32'}), how='left', left_on='exp_measurement', right_on='Id')
-        peaks["Well"] = ["".join([w[0],w[1:].zfill(2)]) for w in peaks["Well"]]
-    out_path = os.path.join(os.path.expanduser(output_folder), "{}_{}_peaks.csv".format(NOW, exp_name))
-    peaks.to_csv(out_path, index=False)
-    return peaks
-
-def write_summary(exp_name, peaks, output_folder):
-    print(peaks.columns)
-    if "Well" in peaks.columns:
-        summary = peaks.groupby(["Well", "exp_cycle"])["event_mass"].describe()
-    else:
-        summary = peaks.groupby(["exp_measurement", "exp_cycle"])["event_mass"].describe()
-    out_path = os.path.join(os.path.expanduser(output_folder), "{}_{}_summary.csv".format(NOW, exp_name))
-    summary.to_csv(out_path)
-
-def write_config(exp_name, output_folder, config):
-    output_path = os.path.join(os.path.expanduser(output_folder), "{}_{}_config.json".format(NOW, exp_name))
-    with open(output_path, "w") as f:
-        json.dump(config, f)
diff --git a/lifescale/mass_peak_caller/peak_caller_gui.py b/lifescale/mass_peak_caller/peak_caller_gui.py
deleted file mode 100644
index d618f3514a25bb44291079c708006335cb7cd049..0000000000000000000000000000000000000000
--- a/lifescale/mass_peak_caller/peak_caller_gui.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-import re
-from datetime import datetime
-from functools import partial
-from operator import itemgetter
-from gooey import Gooey, GooeyParser
-import naive_peaks
-import configure_peakcaller
-
-DISPATCHER = {
-    "call_peaks": naive_peaks.call_peaks,
-    "config": configure_peakcaller.configure_peakcaller
-}
-
-def show_error_modal(error_msg):
-    """ Spawns a modal with error_msg"""
-    # wx imported locally so as not to interfere with Gooey
-    import wx
-    app = wx.App()
-    dlg = wx.MessageDialog(None, error_msg, 'Error', wx.ICON_ERROR)
-    dlg.ShowModal()
-    dlg.Destroy()
-
-def add_call_peak_gui(subs, config):
-    p = subs.add_parser('call_peaks', prog='Call Mass Peaks', help='Get Mass Peaks from Raw Lifescale Data')
-    p.add_argument(
-        'experiment',
-        metavar='Choose an Experiment',
-        help='Choose the name of an experiment',
-        widget='Dropdown',
-        choices=naive_peaks.list_experiments(config)[0])
-    p.add_argument('output_folder', widget="DirChooser")
-    p.add_argument('--metadata_file', '-f', widget="FileChooser", help="If provided, convert vial ids to sample names. Should be the exported csv file called PanelData.csv.")
-
-def add_config_gui(subs, config):
-    p = subs.add_parser('config', prog="Configure Program", help="Options to change where this program looks for data, and the calibration used for frequency to mass conversion.")
-    p.add_argument('--raw_data_folder', widget="DirChooser", help="currently {}".format(config["raw_data_folder"]))
-    p.add_argument('--mass_transformation', type=float, help='currently {} Hz/fg'.format(config["mass_transformation"]))
-    p.add_argument('--mass_cutoff', '-m', type=float, default=20, help='currently {} fg - minimum mass of the peak (minimum 5fg recommended)'.format(config["mass_cutoff"]))
-    p.add_argument('--peak_width_cutoff', '-w', type=float, default=5, help='currently {} - width cutoff for peaks - minimum datapoints looking larger than noise'.format(config["peak_width_cutoff"]))
-    p.add_argument('--peak_distance_cutoff', '-d', type=float, default=5, help='currently {} - distance cutoff for peaks - minimum datapoints between peaks'.format(config["peak_distance_cutoff"]))
-
-@Gooey(program_name='Mass Peak Caller', image_dir='./images', required_cols=1)
-def main():
-    current_config, file_not_found = configure_peakcaller.load_config()
-    if file_not_found:
-        show_error_modal("No configuration file found at {}.\nWrote default configuration to that location.\nContinuing with default config.".format(file_not_found))
-
-    parser = GooeyParser(description='Get Mass Peaks from Raw Lifescale Data')
-    subs = parser.add_subparsers(help='commands', dest='command')
-    add_call_peak_gui(subs, current_config)
-    add_config_gui(subs, current_config)
-
-    args = parser.parse_args()
-    opts = vars(args)
-    func = partial(DISPATCHER[args.command], config=current_config)
-    current_config = func(**opts)
-
-if __name__ == '__main__':
-    main()
diff --git a/lifescale/models/ls_data.py b/lifescale/models/ls_data.py
index 0eee038070eec24094aa9ab396aaf9e88873f685..44c70f2a1a9194b0f2755dd284fe1805221a816c 100644
--- a/lifescale/models/ls_data.py
+++ b/lifescale/models/ls_data.py
@@ -206,7 +206,7 @@ class LSData:
             if item_not_nan_max_idx is np.nan:  # No items that are not NaN!
                 settings_dict[row[0]] = None
             else:
-                tmp_list = short_row[:item_not_nan_max_idx].to_list()
+                tmp_list = short_row.loc[:item_not_nan_max_idx].to_list()
                 num_items = len(tmp_list)
                 if num_items == 1:
                     settings_dict[row[0]] = tmp_list[0]
@@ -399,7 +399,9 @@ class LSData:
 
     def export_csv_files(self, output_filepath, verbose=True, sort_by_time=False):
         """Write CSV files to output directory"""
-        print('Write output')
+        if verbose:
+            print('Write output')
+
         # Checks:
         if not os.path.exists(output_filepath):
             raise AssertionError(f'The output path does not exist: {output_filepath}')
@@ -468,6 +470,7 @@ class LSData:
         else:
             return f'Not data available yet.'
 
+
 def remove_space_from_column_names(df):
     """Removes white space from column names of input dataframe."""
     col_names = df.columns
@@ -477,13 +480,14 @@ def remove_space_from_column_names(df):
     df.columns = col_names_corrected
     return df
 
+
 def row_to_list(row) -> list:
     """Convert dataframe row to list and remove all trailing NaN values."""
     item_not_nan_max_idx = row.loc[~row.isna()].index.max()
     if item_not_nan_max_idx is np.nan:  # No items that are not NaN!
         out_list = []
     else:
-        out_list = row[:item_not_nan_max_idx].to_list()
+        out_list = row.loc[:item_not_nan_max_idx].to_list()
     return out_list
 
 
diff --git a/lifescale/scripts/ls2csv.py b/lifescale/scripts/ls2csv.py
new file mode 100644
index 0000000000000000000000000000000000000000..f13a56795bb5a1e1a748701fa3ef79c42062024c
--- /dev/null
+++ b/lifescale/scripts/ls2csv.py
@@ -0,0 +1,11 @@
+"""Converstion program from xlsm to csv
+
+Copyright (C) 2022  Andreas Hellerschmied <heller182@gmx.at>"""
+
+from lifescale.models.ls_data import LSData
+
+
+def ls2csv(xlsm_filename, oputput_dir, verbose=True):
+    """Convert lifescale output file (xlsm) to csv files."""
+    ls_data = LSData.from_xlsm_file(input_xlsm_filename=xlsm_filename, verbose=verbose)
+    ls_data.export_csv_files(oputput_dir, verbose=verbose)
diff --git a/lifescale/scripts/run_gui.py b/lifescale/scripts/run_gui.py
index 6cbf1022c1da200e77c81e4cbd1bf513f8712b74..6d8f018a56a3fae620af93a36f8301e5cc217b24 100644
--- a/lifescale/scripts/run_gui.py
+++ b/lifescale/scripts/run_gui.py
@@ -1,4 +1,7 @@
-"""Start the lifescale GUI from here!"""
+"""Start the lifescale GUI from here!
+
+Copyright (C) 2022  Andreas Hellerschmied <heller182@gmx.at>
+"""
 from lifescale.gui.gui_main import main
 
 
diff --git a/makefile b/makefile
index d2ff4f1f5b70aba7ad29f4c775902cfd41c1975c..54c3aa9b332bea0dfe517994b7cdf84a86f6c8d1 100644
--- a/makefile
+++ b/makefile
@@ -12,3 +12,19 @@ init:
 # Convert *.ui files from Qt Designer to Python files:
 py_gui:
 	pyuic6 -o lifescale/gui/MainWindow.py lifescale/gui/MainWindow.ui
+
+# Package test (install in current virtual environment, editable install with pip)
+test_pack:
+	pip install -e .
+
+# Uninstall test package
+test_pack_uninstall:
+	pip uninstall gravtools
+
+# Build package with setuptools (new version):
+build:
+	python -m build
+
+# Upload package to pypi.org
+pypi_push:
+	twine upload --verbose dist/*
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..07de284aa5c45f56b69ca6f605edf72a14785b99
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..a82a87c774f2e43f9dbe360234184b299259c59b
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,28 @@
+[metadata]
+name = lifescale-utils
+version = attr: lifescale.__version__
+author = Andreas Hellerschmied
+author_email = heller182@gmx.at
+url = https://gitlab.com/hellerdev/lifescale_utils
+description = Lifescale utility software.
+long_description = file: README.md
+long_description_content_type = text/markdown
+keywords = Lifescale
+license = GNU GPLv3
+classifiers =
+    License :: OSI Approved :: GNU General Public License (GPL)
+    Programming Language :: Python :: 3
+
+[options]
+python_requires = >=3.6, <4
+packages = find:
+zip_safe = True
+include_package_data = True
+install_requires =
+    numpy
+    pandas
+    openpyxl
+
+[options.entry_points]
+console_scripts =
+    ls2csv = lifescale.command_line.command_line:ls2csv