diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 77a4ea8ce181979f6f77d01a7a0b503bbfbdf86d..59649197810c2f3472929ce78d115b08da21b25d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -24,3 +24,4 @@ make_test:
     - source setup.sh
     - source /media/data_hdd/Xilinx/Vivado/2021.1/settings64.sh
     - source tests/startup.sh -i 210308B0B4F5 -k 192.168.0.12 -p 192.168.2.3:ch2
+    - source tests/software_emulator.sh
diff --git a/README.md b/README.md
index b74fcffecabe65d2bf662714bb4ed9ccd08561a1..0fe370fe68b00d153d2aa90e1b7d54d0837be3dd 100644
--- a/README.md
+++ b/README.md
@@ -44,11 +44,52 @@ pip install --editable .
 
 To install IPbus please see the [IPbus user guide](https://ipbus.web.cern.ch/doc/user/html/software/installation.html).
 
+The software emulator also runs without ipbus installed.
+To use a container with preinstalled dependencies, refer to [this section](https://gitlab.cern.ch/cms-etl-electronics/module_test_sw#using-docker) (needs docker installed).
+
 ## Running the code
 
 To properly set all paths run `source setup.sh`.
 
-A minimal example of usage of this package is given in `test_tamalero.py`, which can be run as:
+### Software only
+
+A software emulator of ETROC2 has been implemented, and examples of its usage are implemented in `test_ETROC.py`.
+The simplest example requests a handful of data words by sending L1As at different threshold values.
+
+``` bash
+ipython3 -i test_ETROC.py
+```
+
+Which should return something like
+
+``` bash
+Running without uhal (ipbus not installed with correct python bindings)
+Sending 10 L1As and reading back data, for the following thresholds:
+[203.0, 202.7, 202.4, 202.1, 201.8, 201.5, 201.2, 200.9, 200.6, 200.3]
+Threshold at th=203.0mV
+Vth set to 203.000000.
+('header', {'elink': 0, 'sof': 0, 'eof': 0, 'full': 0, 'any_full': 0, 'global_full': 0, 'l1counter': 1, 'type': 0, 'bcid': 0})
+('trailer', {'elink': 0, 'sof': 0, 'eof': 0, 'full': 0, 'any_full': 0, 'global_full': 0, 'chipid': 25152, 'status': 0, 'hits': 0, 'crc': 0})
+Threshold at th=202.7mV
+Vth set to 202.700000.
+('header', {'elink': 0, 'sof': 0, 'eof': 0, 'full': 0, 'any_full': 0, 'global_full': 0, 'l1counter': 2, 'type': 0, 'bcid': 0})
+('trailer', {'elink': 0, 'sof': 0, 'eof': 0, 'full': 0, 'any_full': 0, 'global_full': 0, 'chipid': 25152, 'status': 0, 'hits': 0, 'crc': 0})
+Threshold at th=202.4mV
+...
+```
+
+A threshold scan can be run with
+
+``` bash
+ipython3 -i test_ETROC.py -- --vth --fitplots
+```
+
+The threshold scans will produce S-curves for each pixel.
+![](output/pixel_1.png)
+
+### With a physical Readout Board
+
+A minimal example of usage of this package with a physical readout board (v1 or v2) is given in `test_tamalero.py`, which can be run as:
 `ipython3 -i test_tamalero.py`
 
 The code is organized similar to the physical objects.
@@ -72,6 +113,7 @@ rb_0.connect_KCU(kcu)
 ```
 
 **Note:** Control hub is now required for using the KCU, as shown in the default `ipb_path` of the KCU (i.e. `"chtcp-2.0://localhost:10203?target=192.168.0.11:50001"` instead of `"ipbusudp-2.0://192.168.0.11:50001"`). `tamalero` won't run otherwise.
+Control hub is part of the IPbus package and can be started with e.g. `/opt/cactus/bin/controlhub_start`.
 
 We can then configure the RB and get a status of the lpGBT:
 ```
diff --git a/output/pixel_1.png b/output/pixel_1.png
new file mode 100644
index 0000000000000000000000000000000000000000..e88142cf762f3b5fc6a4fb624ac27c54d6175cb0
Binary files /dev/null and b/output/pixel_1.png differ
diff --git a/requirements.txt b/requirements.txt
index e939ad3ca7c2e43dd998ef014adb24fca2230962..e594c68b6e4a4ac1f58fc856570d7a525cdfe8ec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
 PyYAML==5.3.1
 requests==2.22.0 
 numpy==1.24.2
+scipy==1.8.0
 yahist==1.14.0
 mplhep==0.3.26
 tqdm==4.64.1
diff --git a/tamalero/KCU.py b/tamalero/KCU.py
index 44566a277b80af25c6912501dc5b3c4eee7e2ec7..e65230b26378adc9bb791552595338ee17b51e4c 100644
--- a/tamalero/KCU.py
+++ b/tamalero/KCU.py
@@ -1,7 +1,10 @@
 """
 Control board class (KCU105). Depends on uhal.
 """
-import uhal
+try:
+    import uhal
+except ModuleNotFoundError:
+    print("Running without uhal (ipbus not installed with correct python bindings)")
 from tamalero.colors import red, green
 
 
diff --git a/test_ETROC.py b/test_ETROC.py
index cd36216ca6a7538b9f4be3912e66e2099d508169..5aaf6bd2703b64d2b96f1589a8a2c6ff057a67c1 100644
--- a/test_ETROC.py
+++ b/test_ETROC.py
@@ -7,6 +7,7 @@ from scipy.optimize import curve_fit
 from matplotlib import pyplot as plt
 
 import os
+import sys
 import json
 from yaml import load, dump
 try:
@@ -14,71 +15,6 @@ try:
 except ImportError:
     from yaml import Loader, Dumper
 
-
-# initiate
-ETROC2 = ETROC(usefake=True) # currently using Software ETROC2 (fake)
-DF = DataFrame('ETROC2')
-
-# argsparser
-import argparse
-argParser = argparse.ArgumentParser(description = "Argument parser")
-argParser.add_argument('--test_readwrite', action='store_true', default=False, help="Test simple read/write functionality?")
-argParser.add_argument('--vth', action='store_true', default=False, help="Parse Vth scan plots?")
-argParser.add_argument('--rerun', action='store_true', default=False, help="Rerun Vth scan and overwrite data?")
-argParser.add_argument('--fitplots', action='store_true', default=False, help="Create individual vth fit plots for all pixels?")
-args = argParser.parse_args()
-
-
-# ==============================
-# === Test simple read/write ===
-# ==============================
-if args.test_readwrite:
-    print("<--- Test simple read/write --->")
-    print("Testing read/write to addresses...")
-    for r in range(16):
-        for c in range(16):
-            for n in range(32):
-                regadr = 'PixR%dC%dCfg%d'%(r,c,n)
-                ETROC2.wr_adr(regadr, 1)
-                readval = ETROC2.rd_adr(regadr)
-                if not(readval == 1):
-                    raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
-            for n in range(8):
-                regadr = 'PixR%dC%dSta%d'%(r,c,n)
-                ETROC2.wr_adr(regadr, 1)
-                readval = ETROC2.rd_adr(regadr)
-                if not(readval == 1):
-                    raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
-    print("Test passed.\n")
-    
-    print("Testing read/write for shared pixels...")
-    for n in range(32):
-        regadr = 'PixR%dC%dCfg%d'%(1,1,n)
-        ETROC2.wr_adr(regadr, 1)
-        for r in range(16):
-            for c in range(16):
-                readval = ETROC2.rd_adr(regadr)
-                if not(readval == 1):
-                    raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
-    print("Test passed.\n")
-    
-    print("Testing read/write with register names...")
-    with open(os.path.expandvars('$TAMALERO_BASE/address_table/ETROC2.yaml'), 'r') as f:
-        regnames = load(f, Loader=Loader)
-    for regname in list(regnames.keys()):
-        for pix in range(256):
-            ETROC2.wr_reg(regname, pix, 1)
-            readval = ETROC2.rd_reg(regname, pix)
-            if not(readval == 1):
-                raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
-    print("Test passed.\n")
-
-
-# ==============================
-# ======= Test Vth scan ========
-# ==============================
-
-
 # ====== HELPER FUNCTIONS ======
 
 # run N L1A's and return packaged ETROC2 dataformat
@@ -118,13 +54,13 @@ def sigmoid_fit(x_axis, y_axis):
 def parse_data(data, N_pix):
     results = np.zeros(N_pix)
     pix_w = int(round(np.sqrt(N_pix)))
-    
+
     for word in data:
         datatype, res = DF.read(word)
         if datatype == 'data':
             pix = toPixNum(res['row_id'], res['col_id'], pix_w)
             results[pix] += 1
- 
+
     return results
 
 
@@ -135,7 +71,7 @@ def vth_scan(ETROC2):
     vth_step =   .25 # step size
     N_steps  = int((vth_max-vth_min)/vth_step)+1 # number of steps
     N_pix    = 16*16 # total number of pixels
-    
+
     vth_axis    = np.linspace(vth_min, vth_max, N_steps)
     run_results = np.empty([N_steps, N_pix])
 
@@ -143,138 +79,226 @@ def vth_scan(ETROC2):
         ETROC2.set_Vth_mV(vth)
         i = int(round((vth-vth_min)/vth_step))
         run_results[i] = parse_data(run(N_l1a), N_pix)
-    
+
     # transpose so each 1d list is for a pixel & normalize
     run_results = run_results.transpose()/N_l1a
     return [vth_axis.tolist(), run_results.tolist()]
 
 
-# ========= Vth SCAN =========
-if args.vth:
-    print("<--- Testing Vth scan --->")
-    
-    # run only if no saved data or we want to rerun
-    if (not os.path.isfile("results/vth_scan.json")) or args.rerun:
-
-        # scan
-        print("No data. Run new vth scan...")
-        result_data = vth_scan(ETROC2)
-        
-        #save
-        if not os.path.isdir('results'):
-            os.makedirs('results') 
-
-        with open("results/vth_scan.json", "w") as outfile:
-            json.dump(result_data, outfile)
-            print("Data saved to results/vth_scan.json\n")
-
-
-    # read and parse vth scan data
-    with open('results/vth_scan.json', 'r') as openfile:
-        vth_scan_data = json.load(openfile)
-    
-    vth_axis = np.array(vth_scan_data[0])
-    hit_rate = np.array(vth_scan_data[1])
-    
-    vth_min = vth_axis[0]  # vth scan range
-    vth_max = vth_axis[-1]
-    N_pix   = len(hit_rate) # total # of pixels
-    N_pix_w = int(round(np.sqrt(N_pix))) # N_pix in NxN layout
-
-
-    # ======= PERFORM FITS =======
-    
-    # fit to sigmoid and save to NxN layout
-    slopes = np.empty([N_pix_w, N_pix_w])
-    means  = np.empty([N_pix_w, N_pix_w])
-    widths = np.empty([N_pix_w, N_pix_w])
-    
-    for pix in range(N_pix):
-        fitresults = sigmoid_fit(vth_axis, hit_rate[pix])
-        r, c = fromPixNum(pix, N_pix_w)
-        slopes[r][c] = fitresults[0]
-        means[r][c]  = fitresults[1]
-        widths[r][c] = 4/fitresults[0]
-    
-    # print out results nicely
-    for r in range(N_pix_w):
-        for c in range(N_pix_w):
-            pix = toPixNum(r, c, N_pix_w)
-            print("{:8s}".format("#"+str(pix)), end='')
-        print("")
-        for c in range(N_pix_w):
-            print("%4.2f"%means[r][c], end='  ')
-        print("")
-        for c in range(N_pix_w):
-            print("+-%2.2f"%widths[r][c], end='  ')
-        print("\n")
-
-
-    # ======= PLOT RESULTS =======
-    
-    # fit results per pixel & save
-    if args.fitplots:
-        print('Creating plots and saving in ./results/...')
-        print('This may take a while.')
-        for expix in range(256):
-            exr   = expix%N_pix_w
-            exc   = int(np.floor(expix/N_pix_w))
-    
-            fig, ax = plt.subplots()
-    
-            plt.title("S curve fit example (pixel #%d)"%expix)
-            plt.xlabel("Vth")
-            plt.ylabel("hit rate")
-    
-            plt.plot(vth_axis, hit_rate[expix], '.-')
-            fit_func = sigmoid(slopes[exr][exc], vth_axis, means[exr][exc])
-            plt.plot(vth_axis, fit_func)
-            plt.axvline(x=means[exr][exc], color='r', linestyle='--')
-            plt.axvspan(means[exr][exc]-widths[exr][exc], means[exr][exc]+widths[exr][exc],
-                        color='r', alpha=0.1)
-    
-            plt.xlim(vth_min, vth_max)
-            plt.grid(True)
-            plt.legend(["data","fit","baseline"])
-    
-            fig.savefig(f'results/pixel_{expix}.png')
-            plt.close(fig)
-            del fig, ax
-    
-    # 2D histogram of the mean
-    fig, ax = plt.subplots()
-    plt.title("Mean values of baseline voltage")
-    cax = ax.matshow(means)
-    
-    fig.colorbar(cax)
-    ax.set_xticks(np.arange(N_pix_w))
-    ax.set_yticks(np.arange(N_pix_w))
-    
-    for i in range(N_pix_w):
-        for j in range(N_pix_w):
-            text = ax.text(j, i, "%.2f\n+/-%.2f"%(means[i,j],widths[i,j]),
-                    ha="center", va="center", color="w", fontsize="xx-small")
-    
-    fig.savefig(f'results/sigmoid_mean_2D.png')
-    plt.show()
-    
-    plt.close(fig)
-    del fig, ax
-    
-    # 2D histogram of the width
-    fig, ax = plt.subplots()
-    plt.title("Width of the sigmoid")
-    cax = ax.matshow(
-        widths,
-        cmap='RdYlGn_r',
-        vmin=0, vmax=5,
-    )
-    
-    fig.colorbar(cax)
-    ax.set_xticks(np.arange(N_pix_w))
-    ax.set_yticks(np.arange(N_pix_w))
-    
-    #cax.set_zlim(0, 10)
-    
-    fig.savefig(f'results/sigmoid_width_2D.png')
-    plt.show()
+if __name__ == '__main__':
+
+    # initiate
+    ETROC2 = ETROC(usefake=True) # currently using Software ETROC2 (fake)
+    DF = DataFrame('ETROC2')
+
+    # argsparser
+    import argparse
+    argParser = argparse.ArgumentParser(description = "Argument parser")
+    argParser.add_argument('--test_readwrite', action='store_true', default=False, help="Test simple read/write functionality?")
+    argParser.add_argument('--vth', action='store_true', default=False, help="Parse Vth scan plots?")
+    argParser.add_argument('--rerun', action='store_true', default=False, help="Rerun Vth scan and overwrite data?")
+    argParser.add_argument('--fitplots', action='store_true', default=False, help="Create individual vth fit plots for all pixels?")
+    args = argParser.parse_args()
+
+
+    # ==============================
+    # === Test simple read/write ===
+    # ==============================
+    # FIXME this needs to be fixed for new ETROC2 register table / structure
+    if args.test_readwrite:
+        print("<--- Test simple read/write --->")
+        print("Testing read/write to addresses...")
+        for r in range(16):
+            for c in range(16):
+                for n in range(32):
+                    regadr = 'PixR%dC%dCfg%d'%(r,c,n)
+                    ETROC2.wr_adr(regadr, 1)
+                    readval = ETROC2.rd_adr(regadr)
+                    if not(readval == 1):
+                        raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
+                for n in range(8):
+                    regadr = 'PixR%dC%dSta%d'%(r,c,n)
+                    ETROC2.wr_adr(regadr, 1)
+                    readval = ETROC2.rd_adr(regadr)
+                    if not(readval == 1):
+                        raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
+        print("Test passed.\n")
+
+        print("Testing read/write for shared pixels...")
+        for n in range(32):
+            regadr = 'PixR%dC%dCfg%d'%(1,1,n)
+            ETROC2.wr_adr(regadr, 1)
+            for r in range(16):
+                for c in range(16):
+                    readval = ETROC2.rd_adr(regadr)
+                    if not(readval == 1):
+                        raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
+        print("Test passed.\n")
+
+        print("Testing read/write with register names...")
+        with open(os.path.expandvars('$TAMALERO_BASE/address_table/ETROC2.yaml'), 'r') as f:
+            regnames = load(f, Loader=Loader)
+        for regname in list(regnames.keys()):
+            for pix in range(256):
+                ETROC2.wr_reg(regname, pix, 1)
+                readval = ETROC2.rd_reg(regname, pix)
+                if not(readval == 1):
+                    raise Exception('Test failed for %s, value read was %d.'%(regname,readval))
+        print("Test passed.\n")
+
+
+    # ==============================
+    # ======= Test Vth scan ========
+    # ==============================
+
+
+
+    # ========= Vth SCAN =========
+    if args.vth:
+        print("<--- Testing Vth scan --->")
+
+        # run only if no saved data or we want to rerun
+        if (not os.path.isfile("results/vth_scan.json")) or args.rerun:
+
+            # scan
+            print("No data. Run new vth scan...")
+            result_data = vth_scan(ETROC2)
+
+            #save
+            if not os.path.isdir('results'):
+                os.makedirs('results')
+
+            with open("results/vth_scan.json", "w") as outfile:
+                json.dump(result_data, outfile)
+                print("Data saved to results/vth_scan.json\n")
+
+
+        # read and parse vth scan data
+        with open('results/vth_scan.json', 'r') as openfile:
+            vth_scan_data = json.load(openfile)
+
+        vth_axis = np.array(vth_scan_data[0])
+        hit_rate = np.array(vth_scan_data[1])
+
+        vth_min = vth_axis[0]  # vth scan range
+        vth_max = vth_axis[-1]
+        N_pix   = len(hit_rate) # total # of pixels
+        N_pix_w = int(round(np.sqrt(N_pix))) # N_pix in NxN layout
+
+
+        # ======= PERFORM FITS =======
+
+        # fit to sigmoid and save to NxN layout
+        slopes = np.empty([N_pix_w, N_pix_w])
+        means  = np.empty([N_pix_w, N_pix_w])
+        widths = np.empty([N_pix_w, N_pix_w])
+
+        for pix in range(N_pix):
+            fitresults = sigmoid_fit(vth_axis, hit_rate[pix])
+            r, c = fromPixNum(pix, N_pix_w)
+            slopes[r][c] = fitresults[0]
+            means[r][c]  = fitresults[1]
+            widths[r][c] = 4/fitresults[0]
+
+        # print out results nicely
+        for r in range(N_pix_w):
+            for c in range(N_pix_w):
+                pix = toPixNum(r, c, N_pix_w)
+                print("{:8s}".format("#"+str(pix)), end='')
+            print("")
+            for c in range(N_pix_w):
+                print("%4.2f"%means[r][c], end='  ')
+            print("")
+            for c in range(N_pix_w):
+                print("+-%2.2f"%widths[r][c], end='  ')
+            print("\n")
+
+
+        # ======= PLOT RESULTS =======
+
+        # fit results per pixel & save
+        if args.fitplots:
+            print('Creating plots and saving in ./results/...')
+            print('This may take a while.')
+            for expix in range(256):
+                exr   = expix%N_pix_w
+                exc   = int(np.floor(expix/N_pix_w))
+
+                fig, ax = plt.subplots()
+
+                plt.title("S curve fit example (pixel #%d)"%expix)
+                plt.xlabel("Vth")
+                plt.ylabel("hit rate")
+
+                plt.plot(vth_axis, hit_rate[expix], '.-')
+                fit_func = sigmoid(slopes[exr][exc], vth_axis, means[exr][exc])
+                plt.plot(vth_axis, fit_func)
+                plt.axvline(x=means[exr][exc], color='r', linestyle='--')
+                plt.axvspan(means[exr][exc]-widths[exr][exc], means[exr][exc]+widths[exr][exc],
+                            color='r', alpha=0.1)
+
+                plt.xlim(vth_min, vth_max)
+                plt.grid(True)
+                plt.legend(["data","fit","baseline"])
+
+                fig.savefig(f'results/pixel_{expix}.png')
+                plt.close(fig)
+                del fig, ax
+
+        # 2D histogram of the mean
+        fig, ax = plt.subplots()
+        plt.title("Mean values of baseline voltage")
+        cax = ax.matshow(means)
+
+        fig.colorbar(cax)
+        ax.set_xticks(np.arange(N_pix_w))
+        ax.set_yticks(np.arange(N_pix_w))
+
+        for i in range(N_pix_w):
+            for j in range(N_pix_w):
+                text = ax.text(j, i, "%.2f\n+/-%.2f"%(means[i,j],widths[i,j]),
+                        ha="center", va="center", color="w", fontsize="xx-small")
+
+        fig.savefig(f'results/sigmoid_mean_2D.png')
+        plt.show()
+
+        plt.close(fig)
+        del fig, ax
+
+        # 2D histogram of the width
+        fig, ax = plt.subplots()
+        plt.title("Width of the sigmoid")
+        cax = ax.matshow(
+            widths,
+            cmap='RdYlGn_r',
+            vmin=0, vmax=5,
+        )
+
+        fig.colorbar(cax)
+        ax.set_xticks(np.arange(N_pix_w))
+        ax.set_yticks(np.arange(N_pix_w))
+
+        #cax.set_zlim(0, 10)
+
+        fig.savefig(f'results/sigmoid_width_2D.png')
+        plt.show()
+
+    else:
+        thresholds = [203-x*0.3 for x in range(10)]
+        print("Sending 10 L1As and reading back data, for the following thresholds:")
+        print(thresholds)
+        for th in thresholds:
+            print(f'Threshold at {th=}mV')
+            ETROC2.set_Vth_mV(th)  # anything between 196 and 203 should give reasonable numbers of hits
+            data = ETROC2.runL1A()  # this will spit out data for a single event, with an occupancy corresponding to the previously set threshold
+            unpacked = [DF.read(d) for d in data]
+            for d in data:
+                print(DF.read(d))
+
+
+        if unpacked[-1][1]['hits'] == len(unpacked)-2:
+            print("Very simple check passed.")
+            sys.exit(0)
+        else:
+            print("Data looks inconsistent.")
+            sys.exit(1)
diff --git a/tests/software_emulator.sh b/tests/software_emulator.sh
new file mode 100644
index 0000000000000000000000000000000000000000..7c3354465286b58027c8798d5c97d2a39d150da0
--- /dev/null
+++ b/tests/software_emulator.sh
@@ -0,0 +1,22 @@
+#! /bin/bash
+
+#### Predefined variables and functions ####
+RED='\033[1;31m'
+GREEN='\033[1;32m'
+BLUE='\033[1;34m'
+NC='\033[0m'
+
+function info() { echo -e "${BLUE}${@}${NC}"; }
+function error() { echo -e "${RED}${@}${NC}"; }
+function success() { echo -e "${GREEN}${@}${NC}"; }
+
+# run test_tamalero with power up
+info "Running test_ETROC..."
+/usr/bin/env python3 test_ETROC.py
+EXIT=$?
+if [ ${EXIT} -ne 0 ]; then
+	error "Failure when running test_ETROC.py; exit code is ${EXIT}. Blocking merge."
+	return ${EXIT}
+else
+	success "Success! test_ETROC.py exit with code ${EXIT}"
+fi