diff --git a/Dockerfile_to_build_upon b/Dockerfile_to_build_upon
new file mode 100644
index 0000000000000000000000000000000000000000..4456cf521a4ae2079654b4d3370189370676b9b9
--- /dev/null
+++ b/Dockerfile_to_build_upon
@@ -0,0 +1 @@
+FROM docker.control.lth.se/marko-g/ur_simple_control:latest
diff --git a/TODOS_2024_09_19 b/TODOS_2024_09_19
index 1c12eb3f18c7901410e674c81bcac4a10a63cf70..ccc314f16944f25eec85e570f8e7c9b3bbe0d13f 100644
--- a/TODOS_2024_09_19
+++ b/TODOS_2024_09_19
@@ -1,3 +1,26 @@
+goal 1: usability, verifiability
+----------------------------------
+1. have default arguments, you're adding/adapting new essential ones often, and copy-pasting
+   them around examples is idiotic and has to end immediatelly -> MOSTLY DONE
+2. fix the logging (as in save logs in an automated way + a parameter to check whether you want a new one or name it or sth)
+3. make logs importable to manipulator visualizer or wherever
+   to compare real and simulated runs specifically.
+   currently we genuinely don't know what the difference is, 
+   and we have no way of judging how well references are tracked.
+   this is obviously essential - we're not eyeballing stuff, 
+   we're verifying.
+4. BONUS to 3.: think of some basic metrics to calculate along 
+   trajectories (print out some info on the performance of point-to-point and traj-following
+   runs, including comparing to same simulate runs etc. plots should be 
+   fine to start, but having running rms or something sounds like a good idea).
+   also make sure x-axis are labelled correctly (wall-clock time)
+5. write some tests just too see that:
+	a) various parameter combinations work
+    b) controllers converge in situations they should converge in
+    c) most basic unit tests on functions
+    d) preferably some of this is runnable on the real robot,
+       or at least sim with --start-from-current pose flag
+
 goal 2: clean up the code
 ---------------------------
 1. test to see everything works
diff --git a/python/examples/clik.py b/python/examples/clik.py
index 85d9db2798046aaa44adece1b5bd1ae546de2462..c5a8c58865919327900ef61af5e4fd1b8169a924 100644
--- a/python/examples/clik.py
+++ b/python/examples/clik.py
@@ -2,11 +2,9 @@ import numpy as np
 import time
 import argparse
 from functools import partial
-from ur_simple_control.managers import ControlLoopManager, RobotManager
-# TODO merge the 2 clik files
-from ur_simple_control.clik.clik_point_to_point import getClikController, controlLoopClik, moveL, compliantMoveL
+from ur_simple_control.managers import getMinimalArgParser, ControlLoopManager, RobotManager
+from ur_simple_control.clik.clik import getClikArgs, getClikController, controlLoopClik, moveL, compliantMoveL
 # TODO write this in managers and automate name generation
-from ur_simple_control.util.logging_utils import saveLog
 
 
 """
@@ -25,76 +23,18 @@ to the script you will be writing. This is kept here
 only as a convenient reference point.
 """
 def get_args():
-    parser = argparse.ArgumentParser(description='Run closed loop inverse kinematics \
-            of various kinds. Make sure you know what the goal is before you run!',
-            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    parser.add_argument('--simulation', action=argparse.BooleanOptionalAction, 
-            help="whether you are running the UR simulator", default=False)
-    parser.add_argument('--robot-ip', type=str, 
-            help="robot's ip address (only needed if running on the real robot)", \
-                    default="192.168.1.102")
-    parser.add_argument('--pinocchio-only', action=argparse.BooleanOptionalAction, 
-            help="whether you want to just integrate with pinocchio", default=False)
-    parser.add_argument('--visualize-manipulator', action=argparse.BooleanOptionalAction, 
-            help="whether you want to visualize the manipulator and workspace with meshcat", default=False)
-    parser.add_argument('--real-time-plotting', action=argparse.BooleanOptionalAction, 
-            help="whether you want to have some real-time matplotlib graphs (parts of log_dict you select)", default=False)
-    parser.add_argument('--gripper', type=str, \
-            help="gripper you're using (no gripper is the default)", 
-                        default="none", choices=['none', 'robotiq', 'onrobot'])
-    parser.add_argument('--goal-error', type=float, \
-            help="the final position error you are happy with", default=1e-2)
-    parser.add_argument('--max-iterations', type=int, \
-            help="maximum allowable iteration number (it runs at 500Hz)", default=100000)
-    parser.add_argument('--acceleration', type=float, \
-            help="robot's joints acceleration. scalar positive constant, max 1.7, and default 0.4. \
-                   BE CAREFUL WITH THIS. the urscript doc says this is 'lead axis acceleration'.\
-                   TODO: check what this means", default=0.3)
-    parser.add_argument('--speed-slider', type=float,\
-            help="cap robot's speed with the speed slider \
-                    to something between 0 and 1, 0.5 by default \
-                    BE CAREFUL WITH THIS.", default=0.5)
-    parser.add_argument("--start-from-current-pose", action=argparse.BooleanOptionalAction, \
-            help="if connected to the robot, read the current pose and set it as the initial pose for the robot. \
-                 very useful and convenient when running simulation before running on real", \
-                         default=False)
-    parser.add_argument('--tikhonov-damp', type=float, \
-            help="damping scalar in tikhonov regularization", default=1e-3)
-    parser.add_argument('--minimum-detectable-force-norm', type=float, \
-            help="we need to disregard noise to converge despite filtering. \
-                  a quick fix is to zero all forces of norm below this argument threshold.",
-                 default=3.0)
-    # TODO add the rest
-    parser.add_argument('--clik-controller', type=str, \
-            help="select which click algorithm you want", \
-            default='dampedPseudoinverse', choices=['dampedPseudoinverse', 'jacobianTranspose', 'invKinmQP'])
-        # maybe you want to scale the control signal
-    parser.add_argument('--controller-speed-scaling', type=float, \
-            default='1.0', help='not actually_used atm')
-    parser.add_argument('--debug-prints', action=argparse.BooleanOptionalAction, \
-            help="print some debug info", default=False)
-    parser.add_argument('--save-log', action=argparse.BooleanOptionalAction, \
-            help="save log data folder in whatever directory you called this in. if it doesn't exists there, it's saved to /tmp/data", default=False)
-    parser.add_argument('--alpha', type=float, \
-            help="force feedback proportional coefficient", \
-            default=0.01)
-    parser.add_argument('--beta', type=float, \
-            help="low-pass filter beta parameter", \
-            default=0.01)
-    parser.add_argument('--past-window-size', type=int, \
-            help="how many timesteps of past data you want to save", default=5)
-
+    parser = getMinimalArgParser()
+    parser.description = 'Run closed loop inverse kinematics \
+    of various kinds. Make sure you know what the goal is before you run!'
+    parser = getClikArgs(parser)
     args = parser.parse_args()
-    if args.gripper and args.simulation:
-        raise NotImplementedError('Did not figure out how to put the gripper in \
-                the simulation yet, sorry :/ . You can have only 1 these flags right now')
     return args
 
 if __name__ == "__main__": 
     args = get_args()
     robot = RobotManager(args)
     Mgoal = robot.defineGoalPointCLI()
-    log_dict, final_iteration = compliantMoveL(args, robot, Mgoal)
+    compliantMoveL(args, robot, Mgoal)
     robot.closeGripper()
     time.sleep(2.0)
     robot.openGripper()
@@ -108,6 +48,6 @@ if __name__ == "__main__":
         robot.killManipulatorVisualizer()
     
     if args.save_log:
-        saveLog(log_dict, final_iteration, args)
+        robot.log_manager.saveLog()
     #loop_manager.stopHandler(None, None)
 
diff --git a/python/examples/compare_logs.py b/python/examples/compare_logs.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/python/examples/data/clik_comparison_0.pickle b/python/examples/data/clik_comparison_0.pickle
new file mode 100644
index 0000000000000000000000000000000000000000..32e22f561c64f552da9018fdfb46aec3f3b0ca90
Binary files /dev/null and b/python/examples/data/clik_comparison_0.pickle differ
diff --git a/python/examples/data/clik_comparison_1.pickle b/python/examples/data/clik_comparison_1.pickle
new file mode 100644
index 0000000000000000000000000000000000000000..0ceb878e17283c30b70f44b5c1e00c57f3c633c7
Binary files /dev/null and b/python/examples/data/clik_comparison_1.pickle differ
diff --git a/python/examples/data/latest_run_0 b/python/examples/data/latest_run_0
new file mode 100644
index 0000000000000000000000000000000000000000..161ff8a1bba90f9074b9539cdc38a3aa1bdd49e3
Binary files /dev/null and b/python/examples/data/latest_run_0 differ
diff --git a/python/examples/data/test2_0.pickle b/python/examples/data/test2_0.pickle
new file mode 100644
index 0000000000000000000000000000000000000000..fc57d202f31f4278b74dddecd55f61deb411beac
Binary files /dev/null and b/python/examples/data/test2_0.pickle differ
diff --git a/python/examples/drawing_from_input_drawing.py b/python/examples/drawing_from_input_drawing.py
index 12314158fba8c230c9c96800db01e5f4137833b9..51f3c262eefad9207c80ec87783c4d5c2826e9a5 100644
--- a/python/examples/drawing_from_input_drawing.py
+++ b/python/examples/drawing_from_input_drawing.py
@@ -14,13 +14,11 @@ import time
 from functools import partial
 from ur_simple_control.visualize.visualize import plotFromDict
 from ur_simple_control.util.draw_path import drawPath
-from ur_simple_control.dmp.dmp import DMP, NoTC,TCVelAccConstrained 
-# TODO merge these clik files as well, they don't deserve to be separate
-# TODO but first you need to clean up clik.py as specified there
-from ur_simple_control.clik.clik_point_to_point import getClikController, moveL, moveUntilContact, controlLoopClik, compliantMoveL
-from ur_simple_control.clik.clik_trajectory_following import map2DPathTo3DPlane, clikCartesianPathIntoJointPath
-from ur_simple_control.managers import ControlLoopManager, RobotManager
-from ur_simple_control.util.calib_board_hacks import calibratePlane, getSpeedInDirectionOfN
+from ur_simple_control.dmp.dmp import getDMPArgs, DMP, NoTC,TCVelAccConstrained 
+from ur_simple_control.clik.clik import getClikArgs, getClikController, moveL, moveUntilContact, controlLoopClik, compliantMoveL, clikCartesianPathIntoJointPath
+from ur_simple_control.util.map2DPathTo3DPlane import map2DPathTo3DPlane
+from ur_simple_control.managers import getMinimalArgParser, ControlLoopManager, RobotManager
+from ur_simple_control.util.calib_board_hacks import getBoardCalibrationArgs, calibratePlane, getSpeedInDirectionOfN
 from ur_simple_control.basics.basics import moveJ
 
 #######################################################################
@@ -31,127 +29,18 @@ def getArgs():
     #######################################################################
     #                          generic arguments                          #
     #######################################################################
-    parser = argparse.ArgumentParser(description='Make a drawing on screen,\
-            watch the robot do it on the whiteboard.',
-            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    # TODO this one won't really work but let's leave it here for the future
-    parser.add_argument('--simulation', action=argparse.BooleanOptionalAction, 
-            help="whether you are running the UR simulator. \
-                    NOTE: doesn't actually work because it's not a physics simulator", \
-                    default=False)
-    parser.add_argument('--robot-ip', type=str, 
-            help="robot's ip address (only needed if running on the real robot)", \
-                    default="192.168.1.102")
-    parser.add_argument('--pinocchio-only', action=argparse.BooleanOptionalAction, \
-            help="whether you want to just integrate with pinocchio.\
-                    NOTE: doesn't actually work because it's not a physics simulator", \
-                    default=False)
-    parser.add_argument('--visualize-manipulator', action=argparse.BooleanOptionalAction, 
-            help="whether you want to visualize the manipulator and workspace with meshcat", default=False)
-    parser.add_argument('--real-time-plotting', action=argparse.BooleanOptionalAction, 
-            help="whether you want to have some real-time matplotlib graphs (parts of log_dict you select)", default=False)
-    parser.add_argument('--gripper', type=str, \
-            help="gripper you're using (no gripper is the default)", 
-                        default="none", choices=['none', 'robotiq', 'onrobot'])
-    parser.add_argument('--acceleration', type=float, \
-            help="robot's joints acceleration. scalar positive constant, \
-            max 1.7, and default 0.4. \
-            BE CAREFUL WITH THIS. the urscript doc says this is 'lead axis acceleration'.\
-            TODO: check what this means", default=0.3)
-    parser.add_argument('--speed-slider', type=float,\
-            help="cap robot's speed with the speed slider \
-                    to something between 0 and 1, 1.0 by default because for dmp. \
-                    BE CAREFUL WITH THIS.", default=1.0)
-    # TODO: make this optional
-    parser.add_argument('--max-iterations', type=int, \
-            help="maximum allowable iteration number (it runs at 500Hz)", default=50000)
-    parser.add_argument('--debug-prints', action=argparse.BooleanOptionalAction, \
-            help="print some debug info", default=False)
-    #######################################################################
-    #                 your controller specific arguments                  #
-    #######################################################################
-    # not applicable here, but leaving it in the case it becomes applicable
-    # it's also in the robot manager even though it shouldn't be
-    parser.add_argument('--past-window-size', type=int, \
-            help="how many timesteps of past data you want to save", default=5)
-    parser.add_argument('--goal-error', type=float, \
-            help="the final position error you are happy with. NOTE: not used here", \
-            default=1e-3)
-    parser.add_argument("--start-from-current-pose", action=argparse.BooleanOptionalAction, \
-            help="if connected to the robot, read the current pose and set it as the initial pose for the robot.\
-                 very useful and convenient when running simulation before running on real", \
-                         default=False)
-    parser.add_argument('--tikhonov-damp', type=float, \
-            help="damping scalar in tikhonov regularization.\
-            This is used when generating the joint trajectory from the drawing.", \
-            default=1e-2)
-    parser.add_argument('--minimum-detectable-force-norm', type=float, \
-            help="we need to disregard noise to converge despite filtering. \
-                  a quick fix is to zero all forces of norm below this argument threshold.",
-                 default=3.0)
-    # TODO add the rest
-    parser.add_argument('--clik-controller', type=str, \
-            help="select which click algorithm you want", \
-            default='dampedPseudoinverse', \
-            choices=['dampedPseudoinverse', 'jacobianTranspose'])
-        # maybe you want to scale the control signal
-    parser.add_argument('--controller-speed-scaling', type=float, \
-            default='1.0', help='not actually_used atm')
-    parser.add_argument('--contact-detecting-force', type=float, \
-            default='1.3', help='the force used to detect contact (collision) in the moveUntilContact function')
-    #############################
-    #  dmp  specific arguments  #
-    #############################
-    parser.add_argument('--temporal-coupling', action=argparse.BooleanOptionalAction, \
-            help="whether you want to use temporal coupling", default=True)
+    parser = getMinimalArgParser()
+    parser = getClikArgs(parser)
+    parser = getDMPArgs(parser)
+    parser = getBoardCalibrationArgs(parser)
     parser.add_argument('--kp', type=float, \
             help="proportial control constant for position errors", \
             default=1.0)
-    parser.add_argument('--tau0', type=float, \
-            help="total time needed for trajectory. if you use temporal coupling,\
-                  you can still follow the path even if it's too fast", \
-            default=5)
-    parser.add_argument('--gamma-nominal', type=float, \
-            help="positive constant for tuning temporal coupling: the higher,\
-            the fast the return rate to nominal tau", \
-            default=1.0)
-    parser.add_argument('--gamma-a', type=float, \
-            help="positive constant for tuning temporal coupling, potential term", \
-            default=0.5)
-    parser.add_argument('--eps-tc', type=float, \
-            help="temporal coupling term, should be small", \
+    parser.add_argument('--kv', type=float, \
+            help="damping in impedance control", \
             default=0.001)
-    parser.add_argument('--alpha', type=float, \
-            help="force feedback proportional coefficient", \
-            default=0.007)
-            #default=0.05)
-    parser.add_argument('--beta', type=float, \
-            help="low-pass filter beta parameter", \
-            default=0.01)
-    # TODO add low pass filtering and make it's parameters arguments too
-    #######################################################################
-    #                       task specific arguments                       #
-    #######################################################################
-    # TODO measure this for the new board
-    parser.add_argument('--board-width', type=float, \
-            help="width of the board (in meters) the robot will write on", \
-            #default=0.5)
-            default=0.25)
-    parser.add_argument('--board-height', type=float, \
-            help="height of the board (in meters) the robot will write on", \
-            #default=0.35)
-            default=0.25)
-    parser.add_argument('--board-wiping', action=argparse.BooleanOptionalAction, \
-            help="are you wiping the board (default is no because you're writing)", \
-            default=False)
-    # TODO: add axis direction too!!!!!!!!!!!!!!!!!
-    # and change the various offsets accordingly
-    parser.add_argument('--board-axis', type=str, \
-            choices=['z', 'y'], \
-            help="(world) axis (direction) in which you want to search for the board", \
-            default="z")
-    parser.add_argument('--calibration', action=argparse.BooleanOptionalAction, \
-            help="whether you want to do calibration", default=False)
+    parser.description = 'Make a drawing on screen,\
+            watch the robot do it on the whiteboard.'
     parser.add_argument('--draw-new', action=argparse.BooleanOptionalAction, \
             help="whether draw a new picture, or use the saved path path_in_pixels.csv", default=True)
     parser.add_argument('--pick-up-marker', action=argparse.BooleanOptionalAction, \
@@ -163,14 +52,9 @@ def getArgs():
             help="""
     whether you want to do find marker offset (recalculate TCP
     based on the marker""", default=False)
-    parser.add_argument('--n-calibration-tests', type=int, \
-            help="number of calibration tests you want to run", default=10)
-    parser.add_argument('--clik-goal-error', type=float, \
-            help="the clik error you are happy with", default=1e-2)
-    parser.add_argument('--max-init-clik-iterations', type=int, \
-            help="number of max clik iterations to get to the first point", default=10000)
-    parser.add_argument('--max-running-clik-iterations', type=int, \
-            help="number of max clik iterations between path points", default=1000)
+    parser.add_argument('--board-wiping', action=argparse.BooleanOptionalAction, \
+            help="are you wiping the board (default is no because you're writing)", \
+            default=False)
     args = parser.parse_args()
     if args.gripper and args.simulation:
         raise NotImplementedError('Did not figure out how to put the gripper in \
@@ -225,12 +109,10 @@ def getMarker(args, robot, rotation_matrix, translation_vector):
     robot.Mgoal = TgoalUP.copy()
     compliantMoveL(args, robot, TgoalUP.copy())
     #moveL(args, robot, TgoalUP.copy())
-    #log_dict, final_iteration = loop_manager.run()
     print("going down to marker")
     robot.Mgoal = Tgoal.copy()
     compliantMoveL(args, robot, Tgoal.copy())
     #moveL(args, robot, Tgoal.copy())
-    #log_dict, final_iteration = loop_manager.run()
     print("picking up marker")
     robot.closeGripper()
     time.sleep(2)
@@ -238,7 +120,6 @@ def getMarker(args, robot, rotation_matrix, translation_vector):
     robot.Mgoal = TgoalUP.copy()
     compliantMoveL(args, robot, TgoalUP.copy())
     #moveL(args, robot, TgoalUP.copy())
-    #log_dict, final_iteration = loop_manager.run()
     print("going back")
     # TODO: this HAS to be a movej
     # in fact all of them should be
@@ -246,7 +127,6 @@ def getMarker(args, robot, rotation_matrix, translation_vector):
     #compliantMoveL(args, robot, Tinit.copy())
     moveJ(args, robot, q_init)
     #moveL(args, robot, Tinit.copy())
-    #log_dict, final_iteration = loop_manager.run()
     print("got back")
 
 """
@@ -596,7 +476,7 @@ if __name__ == "__main__":
     controlLoop = partial(controlLoopWriting, dmp, tc, controller, robot)
     loop_manager = ControlLoopManager(robot, controlLoop, args, save_past_dict, log_item)
     # and now we can actually run
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
     #loop_manager.stopHandler(None, None)
     mtool = robot.getT_w_e()
     print("move a bit back")
@@ -609,12 +489,9 @@ if __name__ == "__main__":
     if args.visualize_manipulator:
         robot.killManipulatorVisualizer()
 
-    #plotFromDict(log_dict, args)
     loop_manager.stopHandler(None, None)
     #robot.stopHandler(None, None)
     #robot.stopHandler(None, None)
-    # plot results
-    #plotFromDict(log_dict, args)
     # TODO: add some math to analyze path precision
 
     
diff --git a/python/examples/path_in_pixels.csv b/python/examples/path_in_pixels.csv
index bc9ca0b92fa4d364f9d15e8237f8bd7088e9ac6d..4cb8ed50a18b76585680c581358239ad6ce96602 100644
--- a/python/examples/path_in_pixels.csv
+++ b/python/examples/path_in_pixels.csv
@@ -1,75 +1,80 @@
-0.51607,0.68193
-0.51829,0.68193
-0.51829,0.68193
-0.52052,0.68041
-0.52052,0.68041
-0.52052,0.67889
-0.52496,0.67738
-0.52718,0.67586
-0.52940,0.67434
-0.53828,0.66826
-0.54050,0.66674
-0.54272,0.66522
-0.54494,0.66370
-0.55605,0.65155
-0.55827,0.65003
-0.56715,0.64396
-0.56715,0.64244
-0.56715,0.64244
-0.56715,0.64092
-0.56715,0.63940
-0.56715,0.63788
-0.56715,0.63636
-0.56715,0.63636
-0.56715,0.63029
-0.55827,0.62117
-0.55827,0.61966
-0.54939,0.61358
-0.52940,0.59080
-0.52496,0.58624
-0.51829,0.58168
-0.48498,0.55890
-0.47388,0.55434
-0.46721,0.55130
-0.44945,0.54371
-0.41836,0.53459
-0.41169,0.53308
-0.40503,0.53308
-0.38726,0.52548
-0.38726,0.52548
-0.38060,0.52548
-0.37394,0.52548
-0.37394,0.52396
+0.54717,0.65459
+0.54494,0.65459
+0.53828,0.65459
+0.53384,0.65459
+0.52718,0.65459
+0.52052,0.65459
+0.51829,0.65459
+0.51163,0.65307
+0.50941,0.65307
+0.49386,0.64548
+0.48054,0.64244
+0.47388,0.64092
+0.43390,0.62421
+0.42724,0.62117
+0.40281,0.60902
+0.39393,0.60295
+0.38504,0.59687
+0.38060,0.59231
+0.35395,0.57257
+0.35395,0.57257
+0.35173,0.56953
+0.34729,0.56042
+0.34507,0.55890
+0.34507,0.55586
+0.34063,0.54523
+0.34063,0.54371
+0.34063,0.54219
+0.34063,0.54067
+0.34063,0.53915
+0.34063,0.53763
+0.34285,0.53459
+0.34285,0.53308
+0.34729,0.53156
+0.35617,0.52852
+0.35839,0.52700
 0.37172,0.52396
-0.36950,0.52396
-0.36950,0.52396
-0.36950,0.52548
-0.36950,0.52548
-0.36950,0.53004
-0.37172,0.53156
-0.37616,0.54219
-0.38060,0.54675
-0.38504,0.55586
-0.38948,0.56497
-0.40503,0.59991
-0.40947,0.60447
-0.41836,0.61358
-0.43612,0.64852
-0.43834,0.65307
-0.44278,0.66370
-0.46499,0.68801
-0.46944,0.69712
-0.47388,0.70168
-0.47832,0.71231
-0.49164,0.72598
-0.49831,0.73661
-0.50719,0.74269
-0.51607,0.75332
-0.52052,0.75788
-0.52274,0.75940
-0.52496,0.76092
-0.52496,0.76244
-0.52718,0.76244
-0.52718,0.76396
-0.52718,0.76396
-0.52718,0.76396
+0.37394,0.52092
+0.38060,0.51940
+0.40725,0.51029
+0.40947,0.50877
+0.41613,0.50725
+0.41836,0.50725
+0.45611,0.49662
+0.45833,0.49662
+0.46499,0.49662
+0.47832,0.49358
+0.48498,0.49358
+0.48720,0.49358
+0.49164,0.49358
+0.49164,0.49358
+0.49386,0.49358
+0.49609,0.49358
+0.49609,0.49510
+0.49609,0.49662
+0.50275,0.50725
+0.50497,0.50877
+0.50497,0.51333
+0.50941,0.52244
+0.51385,0.53004
+0.51829,0.54067
+0.52718,0.57409
+0.52940,0.58320
+0.53606,0.59383
+0.55161,0.62573
+0.55605,0.63636
+0.56049,0.64548
+0.56493,0.65003
+0.58492,0.67282
+0.58936,0.68345
+0.59158,0.68801
+0.61601,0.71383
+0.61823,0.71535
+0.61823,0.72142
+0.62045,0.72294
+0.62045,0.72294
+0.62045,0.72446
+0.62045,0.72598
+0.62045,0.72598
+0.62045,0.72750
+0.62045,0.72750
diff --git a/python/examples/point_impedance_control.py b/python/examples/point_impedance_control.py
index e09487350efbfea204dc9b262177625db2344a6f..1a2728ff68c363efbd13b2685028a74c00ba95ae 100644
--- a/python/examples/point_impedance_control.py
+++ b/python/examples/point_impedance_control.py
@@ -1,6 +1,5 @@
 import pinocchio as pin
 import numpy as np
-import matplotlib.pyplot as plt
 import copy
 import argparse
 import time
@@ -8,14 +7,9 @@ from functools import partial
 from ur_simple_control.visualize.visualize import plotFromDict
 from ur_simple_control.util.draw_path import drawPath
 from ur_simple_control.dmp.dmp import DMP, NoTC,TCVelAccConstrained 
-# TODO merge these clik files as well, they don't deserve to be separate
-# TODO but first you need to clean up clik.py as specified there
-from ur_simple_control.clik.clik_point_to_point import getClikController, moveL, moveUntilContact
-from ur_simple_control.clik.clik_trajectory_following import map2DPathTo3DPlane, clikCartesianPathIntoJointPath
-from ur_simple_control.managers import ControlLoopManager, RobotManager
-from ur_simple_control.util.calib_board_hacks import calibratePlane, getSpeedInDirectionOfN
+from ur_simple_control.clik.clik import getClikArgs, getClikController, moveL, moveUntilContact
+from ur_simple_control.managers import getMinimalArgParser, ControlLoopManager, RobotManager
 from ur_simple_control.basics.basics import moveJ
-import matplotlib
 
 #######################################################################
 #                            arguments                                #
@@ -25,137 +19,18 @@ def getArgs():
     #######################################################################
     #                          generic arguments                          #
     #######################################################################
-    parser = argparse.ArgumentParser(description='Make a drawing on screen,\
-            watch the robot do it on the whiteboard.',
-            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    # TODO this one won't really work but let's leave it here for the future
-    parser.add_argument('--simulation', action=argparse.BooleanOptionalAction, 
-            help="whether you are running the UR simulator. \
-                    NOTE: doesn't actually work because it's not a physics simulator", \
-                    default=False)
-    parser.add_argument('--robot-ip', type=str, 
-            help="robot's ip address", \
-                    default="192.168.1.102")
-    parser.add_argument('--pinocchio-only', action=argparse.BooleanOptionalAction, 
-            help="whether you want to just integrate with pinocchio.\
-                    NOTE: doesn't actually work because it's not a physics simulator", \
-                    default=False)
-    parser.add_argument('--visualize-manipulator', action=argparse.BooleanOptionalAction, 
-            help="whether you want to visualize the manipulator and workspace with meshcat", default=False)
-    parser.add_argument('--real-time-plotting', action=argparse.BooleanOptionalAction, 
-            help="whether you want to have some real-time matplotlib graphs (parts of log_dict you select)", default=False)
-    parser.add_argument('--gripper', type=str, \
-            help="gripper you're using (no gripper is the default)", 
-                        default="none", choices=['none', 'robotiq', 'onrobot'])
-    parser.add_argument('--acceleration', type=float, \
-            help="robot's joints acceleration. scalar positive constant, \
-            max 1.7, and default 0.4. \
-            BE CAREFUL WITH THIS. the urscript doc says this is 'lead axis acceleration'.\
-            TODO: check what this means", default=0.3)
-    parser.add_argument('--speed-slider', type=float,\
-            help="cap robot's speed with the speed slider \
-                    to something between 0 and 1, 1.0 by default because for dmp. \
-                    BE CAREFUL WITH THIS.", default=1.0)
-    parser.add_argument('--max-iterations', type=int, \
-            help="maximum allowable iteration number (it runs at 500Hz)", default=500000)
-    #######################################################################
-    #                 your controller specific arguments                  #
-    #######################################################################
-    # not applicable here, but leaving it in the case it becomes applicable
-    # it's also in the robot manager even though it shouldn't be
-    parser.add_argument('--cartesian-space-impedance', action=argparse.BooleanOptionalAction, \
-            help="is the impedance computed and added in cartesian or in joint space", default=False)
-    parser.add_argument('--past-window-size', type=int, \
-            help="how many timesteps of past data you want to save", default=5)
-    parser.add_argument('--goal-error', type=float, \
-            help="the final position error you are happy with. NOTE: not used here", \
-            default=1e-3)
-    # TODO: test the interaction of this and the overall demo
-    parser.add_argument("--start-from-current-pose", action=argparse.BooleanOptionalAction, \
-            help="if connected to the robot, read the current pose and set it as the initial pose for the robot. \
-                 very useful and convenient when running simulation before running on real", \
-                         default=False)
-    parser.add_argument('--tikhonov-damp', type=float, \
-            help="damping scalar in tikhonov regularization.\
-            This is used when generating the joint trajectory from the drawing.", \
-            default=1e-2)
-    # TODO add the rest
-    parser.add_argument('--clik-controller', type=str, \
-            help="select which click algorithm you want", \
-            default='dampedPseudoinverse', \
-            choices=['dampedPseudoinverse', 'jacobianTranspose', 'invKinmQP'])
-        # maybe you want to scale the control signal
-    parser.add_argument('--controller-speed-scaling', type=float, \
-            default='1.0', help='not actually_used atm')
-    #############################
-    #  dmp  specific arguments  #
-    #############################
-    parser.add_argument('--temporal-coupling', action=argparse.BooleanOptionalAction, \
-            help="whether you want to use temporal coupling", default=True)
+    parser = getMinimalArgParser()
+    parser = getClikArgs(parser)
     parser.add_argument('--kp', type=float, \
             help="proportial control constant for position errors", \
             default=1.0)
     parser.add_argument('--kv', type=float, \
             help="damping in impedance control", \
             default=0.001)
-    parser.add_argument('--tau0', type=float, \
-            help="total time needed for trajectory. if you use temporal coupling,\
-                  you can still follow the path even if it's too fast", \
-            default=5)
-    parser.add_argument('--gamma-nominal', type=float, \
-            help="positive constant for tuning temporal coupling: the higher,\
-            the fast the return rate to nominal tau", \
-            default=1.0)
-    parser.add_argument('--gamma-a', type=float, \
-            help="positive constant for tuning temporal coupling, potential term", \
-            default=0.5)
-    parser.add_argument('--eps-tc', type=float, \
-            help="temporal coupling term, should be small", \
-            default=0.001)
-    parser.add_argument('--alpha', type=float, \
-            help="force feedback proportional coefficient", \
-            default=0.05)
-    parser.add_argument('--beta', type=float, \
-            help="low-pass filter beta parameter", \
-            default=0.01)
-    #######################################################################
-    #                       task specific arguments                       #
-    #######################################################################
-    # TODO measure this for the new board
-    parser.add_argument('--board-width', type=float, \
-            help="width of the board (in meters) the robot will write on", \
-            default=0.3)
-    parser.add_argument('--board-height', type=float, \
-            help="height of the board (in meters) the robot will write on", \
-            default=0.3)
-    parser.add_argument('--calibration', action=argparse.BooleanOptionalAction, \
-            help="whether you want to do calibration", default=False)
-    parser.add_argument('--draw-new', action=argparse.BooleanOptionalAction, \
-            help="whether draw a new picture, or use the saved path path_in_pixels.csv", default=True)
-    parser.add_argument('--pick_up_marker', action=argparse.BooleanOptionalAction, \
-            help="""
-    whether the robot should pick up the marker.
-    NOTE: THIS IS FROM A PREDEFINED LOCATION.
-    """, default=True)
-    parser.add_argument('--find-marker-offset', action=argparse.BooleanOptionalAction, \
-            help="""
-    whether you want to do find marker offset (recalculate TCP
-    based on the marker""", default=False)
-    parser.add_argument('--n-calibration-tests', type=int, \
-            help="number of calibration tests you want to run", default=10)
-    parser.add_argument('--clik-goal-error', type=float, \
-            help="the clik error you are happy with", default=1e-2)
-    parser.add_argument('--max-init-clik-iterations', type=int, \
-            help="number of max clik iterations to get to the first point", default=10000)
-    parser.add_argument('--max-running-clik-iterations', type=int, \
-            help="number of max clik iterations between path points", default=1000)
-    parser.add_argument('--debug-prints', action=argparse.BooleanOptionalAction, \
-            help="print some debug info", default=False)
+    parser.add_argument('--cartesian-space-impedance', action=argparse.BooleanOptionalAction, \
+        help="is the impedance computed and added in cartesian or in joint space", default=False)
 
     args = parser.parse_args()
-    if args.gripper and args.simulation:
-        raise NotImplementedError('Did not figure out how to put the gripper in \
-                the simulation yet, sorry :/ . You can have only 1 these flags right now')
     return args
 
 
@@ -285,7 +160,6 @@ if __name__ == "__main__":
     #                           software setup                            #
     #######################################################################
 
-    #matplotlib.use('tkagg')
     args = getArgs()
     if args.debug_prints:
         print("you will get a lot of stuff printed out, as requested")
@@ -323,7 +197,7 @@ if __name__ == "__main__":
 
     #moveJ(args, robot, dmp.pos.reshape((6,)))
     # and now we can actually run
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
 
     #plotFromDict(log_dict, args)
     # plotting is now initiated in stophandler because then we get the plot 
diff --git a/python/examples/test_gripper.py b/python/examples/test_gripper.py
index 2b1102dd09a4a8f0fa30504ff2a69826b3364bd3..2493fa583530e03a42f8711448251e5fc13fa85a 100644
--- a/python/examples/test_gripper.py
+++ b/python/examples/test_gripper.py
@@ -211,6 +211,6 @@ if __name__ == "__main__":
 #        }
 #    # we're not using any past data or logging, hence the empty arguments
 #    loop_manager = ControlLoopManager(robot, controlLoop, args, {}, log_dict)
-#    log_dict, final_iteration = loop_manager.run()
+#    loop_manager.run()
 #    saveLog(log_dict, final_iteration, args)
 
diff --git a/python/examples/test_movej.py b/python/examples/test_movej.py
index c00d3683f8534117080ae5dfce6f59c27c70b534..be853026c99557a5533ae34e0542f8a42fa640d2 100644
--- a/python/examples/test_movej.py
+++ b/python/examples/test_movej.py
@@ -192,7 +192,7 @@ if __name__ == "__main__":
     q_init[5] += 0.1
     moveJ(args, robot, q_init)
     # and now we can actually run
-#    log_dict, final_iteration = loop_manager.run()
+#    loop_manager.run()
 
     #plotFromDict(log_dict, args)
     # plotting is now initiated in stophandler because then we get the plot 
diff --git a/python/ur_simple_control/__pycache__/managers.cpython-312.pyc b/python/ur_simple_control/__pycache__/managers.cpython-312.pyc
index ac78c9ecfbfb2b36f7db8dfd8b9ca6621c026c2d..49cdc1ff94ca1a8be35034d322263985a15a4d32 100644
Binary files a/python/ur_simple_control/__pycache__/managers.cpython-312.pyc and b/python/ur_simple_control/__pycache__/managers.cpython-312.pyc differ
diff --git a/python/ur_simple_control/basics/basics.py b/python/ur_simple_control/basics/basics.py
index d81beb36ff7ec21e6520d05bfab108ca4915d6ed..4f424098d8ba7b728f84a2dc541580ae9562eec8 100644
--- a/python/ur_simple_control/basics/basics.py
+++ b/python/ur_simple_control/basics/basics.py
@@ -56,7 +56,7 @@ def moveJ(args, robot, q_desired):
     controlLoop = partial(moveJControlLoop, q_desired, robot)
     # we're not using any past data or logging, hence the empty arguments
     loop_manager = ControlLoopManager(robot, controlLoop, args, {}, {})
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
     # TODO: remove, this isn't doing anything
     #time.sleep(0.01)
     if args.debug_prints:
diff --git a/python/ur_simple_control/clik/clik_point_to_point.py b/python/ur_simple_control/clik/clik.py
similarity index 69%
rename from python/ur_simple_control/clik/clik_point_to_point.py
rename to python/ur_simple_control/clik/clik.py
index 83ab327900f78d78898072c0ca5c86934b3b426d..a422e1667bd1bebe55cce2ed816a8542a106ddca 100644
--- a/python/ur_simple_control/clik/clik_point_to_point.py
+++ b/python/ur_simple_control/clik/clik.py
@@ -6,75 +6,58 @@ from functools import partial
 from ur_simple_control.managers import ControlLoopManager, RobotManager
 import time
 from qpsolvers import solve_qp
+import argparse
 
-def get_args():
+def getClikArgs(parser):
     """
-    Every imaginable magic number, flag and setting is put in here.
+    getClikArgs
+    ------------
+    Every clik-related magic number, flag and setting is put in here.
     This way the rest of the code is clean.
+    Force filtering is considered a core part of these control loops
+    because it's not necessarily the same as elsewhere.
     If you want to know what these various arguments do, just grep 
     though the code to find them (but replace '-' with '_' in multi-word arguments).
     All the sane defaults are here, and you can change any number of them as an argument
     when running your program. If you want to change a drastic amount of them, and
     not have to run a super long commands, just copy-paste this function and put your
     own parameters as default ones.
-    NOTE1: the args object obtained from args = parser.get_args()
-    is pushed all around the rest of the code (because it's tidy), so don't change their names.
-    NOTE2: that you need to copy-paste and add aguments you need
-    to the script you will be writing. This is kept here 
-    only as a convenient reference point.
     """
-    parser = argparse.ArgumentParser(description='Run closed loop inverse kinematics \
-            of various kinds. Make sure you know what the goal is before you run!',
-            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    parser.add_argument('--simulation', action=argparse.BooleanOptionalAction, 
-            help="whether you are running the UR simulator", default=False)
-    parser.add_argument('--robot-ip', type=str, 
-            help="robot's ip address", \
-                    default="192.168.1.102")
-    parser.add_argument('--debug_prints', action=argparse.BooleanOptionalAction, 
-            help="print some info for debugging", default=False)
-    parser.add_argument('--pinocchio-only', action=argparse.BooleanOptionalAction, 
-            help="whether you want to just integrate with pinocchio", default=False)
-    parser.add_argument('--visualize-manipulator', action=argparse.BooleanOptionalAction, 
-            help="whether you want to visualize the manipulator and workspace with meshcat", default=False)
-    parser.add_argument('--real-time-plotting', action=argparse.BooleanOptionalAction, 
-            help="whether you want to have some real-time matplotlib graphs (parts of log_dict you select)", default=False)
-    parser.add_argument('--gripper', action=argparse.BooleanOptionalAction, \
-            help="whether you're using the gripper", default=False)
+    #parser = argparse.ArgumentParser(description='Run closed loop inverse kinematics \
+    #        of various kinds. Make sure you know what the goal is before you run!',
+    #        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    ####################
+    #  clik arguments  #
+    ####################
     parser.add_argument('--goal-error', type=float, \
             help="the final position error you are happy with", default=1e-2)
-    parser.add_argument('--max-iterations', type=int, \
-            help="maximum allowable iteration number (it runs at 500Hz)", default=100000)
-    parser.add_argument('--acceleration', type=float, \
-            help="robot's joints acceleration. scalar positive constant, max 1.7, and default 0.4. \
-                   BE CAREFUL WITH THIS. the urscript doc says this is 'lead axis acceleration'.\
-                   TODO: check what this means", default=0.3)
-    parser.add_argument('--speed-slider', type=float,\
-            help="cap robot's speed with the speed slider \
-                    to something between 0 and 1, 0.5 by default \
-                    BE CAREFUL WITH THIS.", default=0.5)
     parser.add_argument('--tikhonov-damp', type=float, \
             help="damping scalar in tikhonov regularization", default=1e-3)
     # TODO add the rest
     parser.add_argument('--clik-controller', type=str, \
             help="select which click algorithm you want", \
-            default='dampedPseudoinverse', choices=['dampedPseudoinverse', 'jacobianTranspose'])
-        # maybe you want to scale the control signal
-    parser.add_argument('--controller-speed-scaling', type=float, \
-            default='1.0', help='not actually_used atm')
+            default='dampedPseudoinverse', choices=['dampedPseudoinverse', 'jacobianTranspose', 'invKinmQP'])
+
+    ###########################################
+    #  force sensing and feedback parameters  #
+    ###########################################
     parser.add_argument('--alpha', type=float, \
             help="force feedback proportional coefficient", \
-            #default=0.01)
-            default=0.05)
+            default=0.01)
     parser.add_argument('--beta', type=float, \
             help="low-pass filter beta parameter", \
             default=0.01)
 
-    args = parser.parse_args()
-    if args.gripper and args.simulation:
-        raise NotImplementedError('Did not figure out how to put the gripper in \
-                the simulation yet, sorry :/ . You can have only 1 these flags right now')
-    return args
+    ###############################
+    #  path following parameters  #
+    ###############################
+    parser.add_argument('--max-init-clik-iterations', type=int, \
+            help="number of max clik iterations to get to the first point", default=10000)
+    parser.add_argument('--max-running-clik-iterations', type=int, \
+            help="number of max clik iterations between path points", default=1000)
+    
+    return parser
 
 #######################################################################
 #                             controllers                             #
@@ -245,7 +228,7 @@ def moveUntilContact(args, robot, speed):
     controlLoop = partial(moveUntilContactControlLoop, args.contact_detecting_force, speed, robot, clik_controller)
     # we're not using any past data or logging, hence the empty arguments
     loop_manager = ControlLoopManager(robot, controlLoop, args, {}, {})
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
     # TODO: remove, this isn't doing anything
     time.sleep(0.01)
     print("Collision detected!!")
@@ -268,7 +251,7 @@ def moveL(args, robot, goal_point):
             'dqs' : np.zeros(robot.model.nq),
         }
     loop_manager = ControlLoopManager(robot, controlLoop, args, {}, log_item)
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
     # TODO: remove, this isn't doing anything
     time.sleep(0.01)
     print("MoveL done: convergence achieved, reached destionation!")
@@ -354,11 +337,119 @@ def compliantMoveL(args, robot, goal_point):
             'wrench': np.zeros(6),
             }
     loop_manager = ControlLoopManager(robot, controlLoop, args, save_past_dict, log_item)
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
     # TODO: remove, this isn't doing anything
     time.sleep(0.01)
     print("compliantMoveL done: convergence achieved, reached destionation!")
-    return log_dict, final_iteration
+
+def clikCartesianPathIntoJointPath(path, args, robot, \
+        clikController, q_init, R, p):
+    """
+    clikCartesianPathIntoJointPath
+    ------------------------------
+    functionality
+    ------------
+    Follows a provided Cartesian path,
+    creates a joint space trajectory for it.
+
+    return
+    ------
+    - joint_space_trajectory to follow the given path.
+
+    arguments
+    ----------
+    - path:
+      --> cartesian path given in task frame
+    TODO: write asserts for these arguments
+    - args:
+      --> all the magic numbers used here better be here
+    - clikController:
+      --> clik controller you're using
+    - robot:
+      --> RobotManager instance (needed for forward kinematics etc)
+    TODO: maybe it's better design to expect path in body frame idk
+          the good thing here is you're forced to think about frames.
+    TODO: make this a kwarg with a neural transform as default.
+    - transf_body_task_frame: 
+      --> A transformation from the body frame to task frame.
+      --> It is assumed that the path was provided in the task frame
+          because 9/10 times that's more convenient,
+          and you can just pass a neural transform if you're not using it.
+    - q_init:
+      --> starting point. 
+      --> you can movej to it before executing the trajectory,
+         so this makes perfect sense to ensure correctness
+    """
+
+    transf_body_to_task_frame = pin.SE3(R, p)
+    q = copy.deepcopy(q_init)
+
+    for i in range(len(path)):
+        path[i] = transf_body_to_task_frame.act(path[i])
+    # TODO remove print, write a test for this instead
+    if args.debug_prints:
+        print(path)
+
+    # TODO: finish this
+    # - pass clik alg as argument
+    # - remove magic numbers
+    # - give output in the right format
+    # skip inital pos tho
+    #q = np.array([-2.256,-1.408,0.955,-1.721,-1.405,-0.31, 0.0, 0.0])
+    #q = np.array([-2.014, -1.469, 1.248, -1.97, -1.366, -0.327, 0.0, 0.0])
+    # this is just init_q right now
+    # TODO: make this a flag or something for readability's sake
+    n_iter = args.max_init_clik_iterations
+    # we don't know how many there will be, so a linked list is 
+    # clearly the best data structure here (instert is o(1) still,
+    # and we aren't pressed on time when turning it into an array later)
+    qs = []
+    for goal in path:
+        Mgoal = pin.SE3(R, goal)
+        for i in range(n_iter):
+            # TODO maybe hide pin call with RobotManager call
+            pin.forwardKinematics(robot.model, robot.data, q)
+            SEerror = robot.data.oMi[robot.JOINT_ID].actInv(Mgoal)
+            err_vector = pin.log6(SEerror).vector 
+            if np.linalg.norm(err_vector) < args.goal_error:
+                if not n_iter == args.max_init_clik_iterations:
+                    if args.debug_prints:
+                        print("converged in", i)
+                    break
+            J = pin.computeJointJacobian(robot.model, robot.data, q, robot.JOINT_ID)
+            qd = clikController(J, err_vector)
+            # we're integrating this fully offline of course
+            q = pin.integrate(robot.model, q, qd * robot.dt)
+            if (not n_iter == args.max_init_clik_iterations) and (i % 10 == 0):
+                qs.append(q[:6])
+
+        # just skipping the first run with one stone
+        if n_iter == args.max_init_clik_iterations:
+            n_iter = args.max_running_clik_iterations
+        else:
+            if i == args.max_running_clik_iterations - 1:
+                print("DID NOT CONVERGE -- exiting")
+                # nothing is moving 
+                # and i'm not even using a manager here
+                # so no need, right?
+                #ControlLoopManager.stopHandler(None, None, None)
+                exit()
+
+    ##############################################
+    #  save the obtained joint-space trajectory  #
+    ##############################################
+    qs = np.array(qs)
+    # we're putting a dmp over this so we already have the timing ready
+    # TODO: make this general, you don't want to depend on other random
+    # arguments (make this one traj_time, then put tau0 = traj_time there
+    t = np.linspace(0, args.tau0, len(qs)).reshape((len(qs),1))
+    joint_trajectory = np.hstack((t, qs))
+    # TODO handle saving more consistently/intentionally
+    # (although this definitely works right now and isn't bad, just mid)
+    # os.makedir -p a data dir and save there, this is ugly
+    np.savetxt("./joint_trajectory.csv", joint_trajectory, delimiter=',', fmt='%.5f')
+    return joint_trajectory
+
 
 if __name__ == "__main__": 
     args = get_args()
@@ -368,4 +459,4 @@ if __name__ == "__main__":
     controlLoop = partial(controlLoopClik, robot, clik_controller)
     # we're not using any past data or logging, hence the empty arguments
     loop_manager = ControlLoopManager(robot, controlLoop, args, {}, {})
-    log_dict, final_iteration = loop_manager.run()
+    loop_manager.run()
diff --git a/python/ur_simple_control/clik/clik_trajectory_following.py b/python/ur_simple_control/clik/clik_trajectory_following.py
deleted file mode 100644
index 96908e43201fee762a344848d0aac3c193121aeb..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/clik/clik_trajectory_following.py
+++ /dev/null
@@ -1,155 +0,0 @@
-# your entry point is a 
-# np.array of shape (N_POINTS, 2)
-# this files results in a timed joint path
-
-# STEP 1: map the pixels to a 3D plane
-# STEP 2: clik that path
-# STEP 3: put result into csv (but also save it in a convenient class/array here)
-
-import numpy as np
-import pinocchio as pin
-import copy
-from ur_simple_control.managers import ControlLoopManager
-
-#######################################################################
-#                    map the pixels to a 3D plane                     #
-#######################################################################
-def map2DPathTo3DPlane(path_2D, width, height):
-    """
-    map2DPathTo3DPlane
-    --------------------
-    TODO: THINK AND FINALIZE THE FRAME
-    TODO: WRITE PRINT ABOUT THE FRAME TO THE USER
-    assumptions:
-    - origin in top-left corner (natual for western latin script writing)
-    - x goes right (from TCP)
-    - z will go away from the board
-    - y just completes the right-hand frame
-    TODO: RIGHT NOW we don't have a right-handed frame lmao, change that where it should be
-    NOTE: this function as well be in the util or drawing file, but whatever for now, it will be done
-          once it will actually be needed elsewhere
-    Returns a 3D path appropriately scaled, and placed into the first quadrant
-    of the x-y plane of the body-frame (TODO: what is the body frame if we're general?)
-    """
-    z = np.zeros((len(path_2D),1))
-    path_3D = np.hstack((path_2D,z))
-    # scale the path to m
-    path_3D[:,0] = path_3D[:,0] * width
-    path_3D[:,1] = path_3D[:,1] * height
-    # in the new coordinate system we're going in the -y direction
-    # TODO this is a demo specific hack, 
-    # make it general for a future release
-    path_3D[:,1] = -1 * path_3D[:,1] + height
-    return path_3D
-
-
-def clikCartesianPathIntoJointPath(path, args, robot, \
-        clikController, q_init, R, p):
-    """
-    clikCartesianPathIntoJointPath
-    ------------------------------
-    functionality
-    ------------
-    Follows a provided Cartesian path,
-    creates a joint space trajectory for it.
-
-    return
-    ------
-    - joint_space_trajectory to follow the given path.
-
-    arguments
-    ----------
-    - path:
-      --> cartesian path given in task frame
-    TODO: write asserts for these arguments
-    - args:
-      --> all the magic numbers used here better be here
-    - clikController:
-      --> clik controller you're using
-    - robot:
-      --> RobotManager instance (needed for forward kinematics etc)
-    TODO: maybe it's better design to expect path in body frame idk
-          the good thing here is you're forced to think about frames.
-    TODO: make this a kwarg with a neural transform as default.
-    - transf_body_task_frame: 
-      --> A transformation from the body frame to task frame.
-      --> It is assumed that the path was provided in the task frame
-          because 9/10 times that's more convenient,
-          and you can just pass a neural transform if you're not using it.
-    - q_init:
-      --> starting point. 
-      --> you can movej to it before executing the trajectory,
-         so this makes perfect sense to ensure correctness
-    """
-
-    transf_body_to_task_frame = pin.SE3(R, p)
-    q = copy.deepcopy(q_init)
-
-    for i in range(len(path)):
-        path[i] = transf_body_to_task_frame.act(path[i])
-    # TODO remove print, write a test for this instead
-    if args.debug_prints:
-        print(path)
-
-    # TODO: finish this
-    # - pass clik alg as argument
-    # - remove magic numbers
-    # - give output in the right format
-    # skip inital pos tho
-    #q = np.array([-2.256,-1.408,0.955,-1.721,-1.405,-0.31, 0.0, 0.0])
-    #q = np.array([-2.014, -1.469, 1.248, -1.97, -1.366, -0.327, 0.0, 0.0])
-    # this is just init_q right now
-    # TODO: make this a flag or something for readability's sake
-    n_iter = args.max_init_clik_iterations
-    # we don't know how many there will be, so a linked list is 
-    # clearly the best data structure here (instert is o(1) still,
-    # and we aren't pressed on time when turning it into an array later)
-    qs = []
-    for goal in path:
-        Mgoal = pin.SE3(R, goal)
-        for i in range(n_iter):
-            # TODO maybe hide pin call with RobotManager call
-            pin.forwardKinematics(robot.model, robot.data, q)
-            SEerror = robot.data.oMi[robot.JOINT_ID].actInv(Mgoal)
-            err_vector = pin.log6(SEerror).vector 
-            if np.linalg.norm(err_vector) < args.clik_goal_error:
-                if not n_iter == args.max_init_clik_iterations:
-                    if args.debug_prints:
-                        print("converged in", i)
-                    break
-            J = pin.computeJointJacobian(robot.model, robot.data, q, robot.JOINT_ID)
-            qd = clikController(J, err_vector)
-            # we're integrating this fully offline of course
-            q = pin.integrate(robot.model, q, qd * robot.dt)
-            if (not n_iter == args.max_init_clik_iterations) and (i % 10 == 0):
-                qs.append(q[:6])
-
-        # just skipping the first run with one stone
-        if n_iter == args.max_init_clik_iterations:
-            n_iter = args.max_running_clik_iterations
-        else:
-            if i == args.max_running_clik_iterations - 1:
-                print("DID NOT CONVERGE -- exiting")
-                # nothing is moving 
-                # and i'm not even using a manager here
-                # so no need, right?
-                #ControlLoopManager.stopHandler(None, None, None)
-                exit()
-
-    ##############################################
-    #  save the obtained joint-space trajectory  #
-    ##############################################
-    qs = np.array(qs)
-    # we're putting a dmp over this so we already have the timing ready
-    # TODO: make this general, you don't want to depend on other random
-    # arguments (make this one traj_time, then put tau0 = traj_time there
-    t = np.linspace(0, args.tau0, len(qs)).reshape((len(qs),1))
-    joint_trajectory = np.hstack((t, qs))
-    # TODO handle saving more consistently/intentionally
-    # (although this definitely works right now and isn't bad, just mid)
-    # os.makedir -p a data dir and save there, this is ugly
-    np.savetxt("./joint_trajectory.csv", joint_trajectory, delimiter=',', fmt='%.5f')
-    return joint_trajectory
-
-
-
diff --git a/python/ur_simple_control/dmp/__pycache__/dmp.cpython-312.pyc b/python/ur_simple_control/dmp/__pycache__/dmp.cpython-312.pyc
index c88d4965a1288a73f8fc9345baa64af0aeeca153..01c73c9ba60d4bd126027f16c31dbe867c811c25 100644
Binary files a/python/ur_simple_control/dmp/__pycache__/dmp.cpython-312.pyc and b/python/ur_simple_control/dmp/__pycache__/dmp.cpython-312.pyc differ
diff --git a/python/ur_simple_control/dmp/dmp.py b/python/ur_simple_control/dmp/dmp.py
index 16f6f154a15cc75ccc6b2f0c83062d020ddecd2c..348cb0ac12eebefc45154825a423bf25451af4c3 100644
--- a/python/ur_simple_control/dmp/dmp.py
+++ b/python/ur_simple_control/dmp/dmp.py
@@ -1,4 +1,5 @@
 import numpy as np
+import argparse
 # TODO:
 # 1. change the dimensions so that they make sense,
 #    i.e. shape = (N_points, dimension_of_points)
@@ -12,6 +13,34 @@ import numpy as np
 # these work fine, but it could be good to play around with them just to
 # see their effect. normally people set them so that you get critical damping
 # as the uncostrained system
+
+def getDMPArgs(parser):
+    #############################
+    #  dmp  specific arguments  #
+    #############################
+    parser.add_argument('--temporal-coupling', action=argparse.BooleanOptionalAction, \
+            help="whether you want to use temporal coupling", default=True)
+    parser.add_argument('--kp', type=float, \
+            help="proportial control constant for position errors", \
+            default=1.0)
+    parser.add_argument('--tau0', type=float, \
+            help="total time needed for trajectory. if you use temporal coupling,\
+                  you can still follow the path even if it's too fast", \
+            default=5)
+    parser.add_argument('--gamma-nominal', type=float, \
+            help="positive constant for tuning temporal coupling: the higher,\
+            the fast the return rate to nominal tau", \
+            default=1.0)
+    parser.add_argument('--gamma-a', type=float, \
+            help="positive constant for tuning temporal coupling, potential term", \
+            default=0.5)
+    parser.add_argument('--eps-tc', type=float, \
+            help="temporal coupling term, should be small", \
+            default=0.001)
+            #default=0.05)
+    return parser
+    
+
 class DMP:
     def __init__(self, trajectory, k=100, d=20, a_s=1, n_bfs=100):
         # TODO load the trajectory here
diff --git a/python/ur_simple_control/managers.py b/python/ur_simple_control/managers.py
index b8d1f2d0040d9efd1f380d90589a53ec04846098..e25f0cded131c2efe8ee96fbee6f754543283de4 100644
--- a/python/ur_simple_control/managers.py
+++ b/python/ur_simple_control/managers.py
@@ -20,7 +20,9 @@ import signal
 from ur_simple_control.util.get_model import get_model
 from collections import deque
 from ur_simple_control.visualize.visualize import plotFromDict, realTimePlotter, manipulatorVisualizer
+from ur_simple_control.util.logging_utils import LogManager
 from multiprocessing import Process, Queue
+import argparse
 
 """
 general notes
@@ -58,6 +60,83 @@ There is an interface to a physics simulator.
 Functions for torque controlled robots exist.
 """
 
+def getMinimalArgParser():
+    """
+    getDefaultEssentialArgs
+    ------------------------
+    returns a parser containing:
+        - essential arguments (run in real or in sim)
+        - parameters for (compliant)moveJ
+        - parameters for (compliant)moveL
+    """
+    parser = argparse.ArgumentParser(description="Run something with \
+            Simple Manipulator Control", \
+            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    #################################################
+    #  general arguments: connection, plotting etc  #
+    #################################################
+    parser.add_argument('--simulation', action=argparse.BooleanOptionalAction, \
+            help="whether you are running the UR simulator", default=False)
+    parser.add_argument('--robot-ip', type=str, 
+            help="robot's ip address (only needed if running on the real robot)", \
+                    default="192.168.1.102")
+    parser.add_argument('--pinocchio-only', action=argparse.BooleanOptionalAction, \
+            help="whether you want to just integrate with pinocchio", default=False)
+    parser.add_argument('--visualize-manipulator', action=argparse.BooleanOptionalAction, \
+            help="whether you want to visualize the manipulator and workspace with meshcat", default=False)
+    parser.add_argument('--real-time-plotting', action=argparse.BooleanOptionalAction, \
+            help="whether you want to have some real-time matplotlib graphs (parts of log_dict you select)", default=False)
+    parser.add_argument('--gripper', type=str, \
+            help="gripper you're using (no gripper is the default)", \
+                        default="none", choices=['none', 'robotiq', 'onrobot'])
+    parser.add_argument('--max-iterations', type=int, \
+            help="maximum allowable iteration number (it runs at 500Hz)", default=100000)
+    parser.add_argument('--speed-slider', type=float,\
+            help="cap robot's speed with the speed slider \
+                    to something between 0 and 1, 0.5 by default \
+                    BE CAREFUL WITH THIS.", default=0.5)
+    parser.add_argument("--start-from-current-pose", action=argparse.BooleanOptionalAction, \
+            help="if connected to the robot, read the current pose and set it as the initial pose for the robot. \
+                 very useful and convenient when running simulation before running on real", \
+                         default=False)
+    parser.add_argument('--acceleration', type=float, \
+            help="robot's joints acceleration. scalar positive constant, max 1.7, and default 0.4. \
+                   BE CAREFUL WITH THIS. the urscript doc says this is 'lead axis acceleration'.\
+                   TODO: check what this means", default=0.3)
+    parser.add_argument('--debug-prints', action=argparse.BooleanOptionalAction, \
+            help="print some debug info", default=False)
+    parser.add_argument('--save-log', action=argparse.BooleanOptionalAction, \
+            help="whether you want to save the log of the run. it saves \
+                        what you pass to ControlLoopManager. check other parameters for saving directory and log name.", default=False)
+    parser.add_argument('--save-dir', type=str, \
+            help="path to where you store your logs. default is ./data, but if that directory doesn't exist, then /tmp/data is created and used.", \
+            default='./data')
+    parser.add_argument('--run-name', type=str, \
+            help="name the whole run/experiment (name of log file). note that indexing of runs is automatic and under a different argument.", \
+            default='none')
+    parser.add_argument('--index-runs', action=argparse.BooleanOptionalAction, \
+            help="if you want more runs of the same name, this option will automatically assign an index to a new run (useful for data collection).", default=True)
+    parser.add_argument('--past-window-size', type=int, \
+            help="how many timesteps of past data you want to save", default=5)
+    # maybe you want to scale the control signal (TODO: NOT HERE)
+    parser.add_argument('--controller-speed-scaling', type=float, \
+            default='1.0', help='not actually_used atm')
+    ########################################
+    #  environment interaction parameters  #
+    ########################################
+    parser.add_argument('--contact-detecting-force', type=float, \
+            default=1.3, help='the force used to detect contact (collision) in the moveUntilContact function')
+    parser.add_argument('--minimum-detectable-force-norm', type=float, \
+            help="we need to disregard noise to converge despite filtering. \
+                  a quick fix is to zero all forces of norm below this argument threshold.",
+                 default=3.0)
+    # TODO make this work without parsing (or make it possible to parse two times)
+    #if (args.gripper != "none") and args.simulation:
+    #    raise NotImplementedError('Did not figure out how to put the gripper in \
+    #            the simulation yet, sorry :/ . You can have only 1 these flags right now')
+    return parser
+
+
 class ControlLoopManager:
     """
     ControlLoopManager
@@ -235,13 +314,8 @@ class ControlLoopManager:
             if self.args.debug_prints:
                 print("terminated real_time_plotter_process")
 
-        # now turn the logs into numpy arrays
-        for key in self.log_dict:
-            if self.args.debug_prints:
-                print("turning log files into numpy arrays")
-            self.log_dict[key] = np.array(self.log_dict[key])
-
-        return self.log_dict, self.final_iteration
+        if self.args.save_log:
+            self.robot_manager.log_manager.storeControlLoopRun(self.log_dict, self.controlLoop.func.__name__, self.final_iteration)
 
     def stopHandler(self, signum, frame):
         """
@@ -296,7 +370,7 @@ class ControlLoopManager:
 #            if self.args.debug_prints:
 #                print("joined manipulator_visualizer_process")
 
-        # need to turn logs into ndarrays here too 
+        # let's plot the loop you stopped
         for key in self.log_dict:
             self.log_dict[key] = np.array(self.log_dict[key])
         plotFromDict(self.log_dict, self.final_iteration, self.args)
@@ -342,9 +416,8 @@ class RobotManager:
         # collision and visual models are none if args.visualize == False
         self.model, self.collision_model, self.visual_model, self.data = \
              get_model()
-        # we're using meshcat exclusively.
-        # there are no good options, 
-        # but this does work and isn't a dead project
+        # start visualize manipulator process if selected.
+        # has to be started here because it lives throughout the whole run
         if args.visualize_manipulator:
             self.manipulator_visualizer_queue = Queue()
             if args.debug_prints:
@@ -352,7 +425,6 @@ class RobotManager:
                 print("ROBOT_MANAGER: i am creating and starting the manipulator visualizer  process")
             self.manipulator_visualizer_process = Process(target=manipulatorVisualizer, 
                                                      args=(self.args, self.model, self.collision_model, self.visual_model, self.manipulator_visualizer_queue, ))
-            # give real-time plotter some time to set itself up
             self.manipulator_visualizer_process.start()
             if args.debug_prints:
                 print("ROBOT_MANAGER: manipulator_visualizer_process started")
@@ -360,6 +432,10 @@ class RobotManager:
             if args.debug_prints:
                 print("ROBOT_MANAGER: i managed to put initializing q to manipulator_visualizer_queue")
 
+        # create log manager if we're saving logs
+        if args.save_log:
+            self.log_manager = LogManager(args)
+        
         # ur specific magic numbers 
         # NOTE: all of this is ur-specific, and needs to be if-ed if other robots are added.
         # TODO: this is 8 in pinocchio and that's what you actually use 
@@ -845,3 +921,4 @@ class RobotManager:
         self.manipulator_visualizer_process.terminate()
         if self.args.debug_prints:
             print("terminated manipulator_visualizer_process")
+
diff --git a/python/ur_simple_control/util/__pycache__/calib_board_hacks.cpython-312.pyc b/python/ur_simple_control/util/__pycache__/calib_board_hacks.cpython-312.pyc
index f3cb936ccba20349b062a79f7f3ea0bac9108256..e346abfc3404c5e04c001cf4c8c41732952a3921 100644
Binary files a/python/ur_simple_control/util/__pycache__/calib_board_hacks.cpython-312.pyc and b/python/ur_simple_control/util/__pycache__/calib_board_hacks.cpython-312.pyc differ
diff --git a/python/ur_simple_control/util/__pycache__/draw_path.cpython-312.pyc b/python/ur_simple_control/util/__pycache__/draw_path.cpython-312.pyc
index 71e35875b7d8a6176729a24085e21d5e5618c2b2..d52205a7faf1718a3c5d1dfcb332277f2b09b2ed 100644
Binary files a/python/ur_simple_control/util/__pycache__/draw_path.cpython-312.pyc and b/python/ur_simple_control/util/__pycache__/draw_path.cpython-312.pyc differ
diff --git a/python/ur_simple_control/util/__pycache__/freedrive.cpython-312.pyc b/python/ur_simple_control/util/__pycache__/freedrive.cpython-312.pyc
index dce20d71598646ad9f177e5cf7aa45a82ee6ab06..d5de4577242d504ac09051918b46a3d816d45fd1 100644
Binary files a/python/ur_simple_control/util/__pycache__/freedrive.cpython-312.pyc and b/python/ur_simple_control/util/__pycache__/freedrive.cpython-312.pyc differ
diff --git a/python/ur_simple_control/util/__pycache__/logging_utils.cpython-312.pyc b/python/ur_simple_control/util/__pycache__/logging_utils.cpython-312.pyc
index 1557c6d021d3fe0b591b7bf92ae68a47a0777933..f47591d995636eee4dd219f3004ed75c1e0574b0 100644
Binary files a/python/ur_simple_control/util/__pycache__/logging_utils.cpython-312.pyc and b/python/ur_simple_control/util/__pycache__/logging_utils.cpython-312.pyc differ
diff --git a/python/ur_simple_control/util/calib_board_hacks.py b/python/ur_simple_control/util/calib_board_hacks.py
index 63bce31c624dd63094349a35bfc6b340e4995231..d53d64851338f3a49892fe0f8a7fe3b2ce11ef08 100644
--- a/python/ur_simple_control/util/calib_board_hacks.py
+++ b/python/ur_simple_control/util/calib_board_hacks.py
@@ -7,9 +7,31 @@ import time
 import copy
 from ur_simple_control.managers import RobotManager
 from ur_simple_control.util.freedrive import freedrive
-from ur_simple_control.clik.clik_point_to_point import moveL, moveUntilContact
+from ur_simple_control.clik.clik import moveL, moveUntilContact
 # used to deal with freedrive's infinite while loop
 import threading
+import argparse
+
+def getBoardCalibrationArgs(parser):
+    parser.add_argument('--board-width', type=float, \
+            help="width of the board (in meters) the robot will write on", \
+            #default=0.5)
+            default=0.25)
+    parser.add_argument('--board-height', type=float, \
+            help="height of the board (in meters) the robot will write on", \
+            #default=0.35)
+            default=0.25)
+    # TODO: add axis direction too!!!!!!!!!!!!!!!!!
+    # and change the various offsets accordingly
+    parser.add_argument('--board-axis', type=str, \
+            choices=['z', 'y'], \
+            help="(world) axis (direction) in which you want to search for the board", \
+            default="z")
+    parser.add_argument('--calibration', action=argparse.BooleanOptionalAction, \
+            help="whether you want to do calibration", default=False)
+    parser.add_argument('--n-calibration-tests', type=int, \
+            help="number of calibration tests you want to run", default=10)
+    return parser
 
 """
 general
diff --git a/python/ur_simple_control/util/default_args.py b/python/ur_simple_control/util/default_args.py
deleted file mode 100644
index 218de8545f1b2c8b13ef45653f48418ac1633911..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/util/default_args.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import pinocchio as pin
-import numpy as np
-import argparse
-
-
-"""
-TODO: have 2 versions:
-    1. only the minimal set of argumets (those needed to load robot 
-       and run anything)
-    2. every single one you have os far
-"""
-def getDefaultArgs():
-    raise NotImplementedError("sorry, this hasn't been implemented yet")
-
diff --git a/python/ur_simple_control/util/draw_path.py b/python/ur_simple_control/util/draw_path.py
index b06db178645e9bdce5a13178429b022089c150a6..29cf10423471560c052ceb02704d0ffebae40ada 100644
--- a/python/ur_simple_control/util/draw_path.py
+++ b/python/ur_simple_control/util/draw_path.py
@@ -18,7 +18,6 @@ import matplotlib.pyplot as plt
 # Thus it is the correct tool for the job and there's no need
 # to reimplement it from mouse events.
 from matplotlib.widgets import LassoSelector
-from ur_simple_control.clik.clik_point_to_point import get_args
 # Path is the most generic matplotlib class.
 # It has some convenient functions to handle the draw line,
 # i.e. collection of points, but just refer to matplotlib 
diff --git a/python/ur_simple_control/util/freedrive.py b/python/ur_simple_control/util/freedrive.py
index e5a55d82b6ca7be309397b45ed1811c2d2253d74..6403437c05a373e787af6e73b17ec40d9bbad8a3 100644
--- a/python/ur_simple_control/util/freedrive.py
+++ b/python/ur_simple_control/util/freedrive.py
@@ -2,12 +2,11 @@ import pinocchio as pin
 import numpy as np
 import time
 import signal
-from ur_simple_control.managers import RobotManager
+from ur_simple_control.managers import RobotManager, getMinimalArgParser
 # TODO: put sane default arguments needed just for managers 
 # into a separate file, call it default arguments.
 # ideally you also only need to add your additional ones
 # to this list.
-from ur_simple_control.clik.clik_point_to_point import get_args
 
 
 def handler(signum, frame):
@@ -29,7 +28,8 @@ def freedrive(robot):
         time.sleep(0.005)
 
 if __name__ == "__main__":
-    args = get_args()
+    parser = getMinimalArgParser()
+    args = parser.parse_args()
     robot = RobotManager(args)
     signal.signal(signal.SIGINT, handler)
     freedrive(robot)
diff --git a/python/ur_simple_control/util/logging_utils.py b/python/ur_simple_control/util/logging_utils.py
index 99d4b3e8878c1095d2ea9d7677c870e510130991..3e80eef1d08852b88281c2e73cc8b6f61b5bb0b4 100644
--- a/python/ur_simple_control/util/logging_utils.py
+++ b/python/ur_simple_control/util/logging_utils.py
@@ -1,58 +1,115 @@
 import pickle
 import numpy as np
 import os
+import subprocess
+import re
 
-def saveLog(log_dict, final_iteration, args):
-    # shave off the zeros, noone needs 'em
-    for key in log_dict:
-        log_dict[key] = log_dict[key][:final_iteration]
-    # TODO make generic:
-    #  - generate name based on args + add index
-    #  - you need to run shell ls to get the index,
-    #    there's at least one chalmers project with code for that
-    if os.path.exists('./data'):
-        run_file_path = "./data/clik_run_001.pickle"
-        args_file_path = "./data/clik_run_001_args.pickle"
-    else:
-        os.makedirs('/tmp/data', exist_ok=True)
-        run_file_path = "/tmp/data/clik_run_001.pickle"
-        args_file_path = "/tmp/data/clik_run_001_args.pickle"
-    # save the logged data
-    # you need to open it binary for pickling
-    log_file = open(run_file_path, 'wb')
-    pickle.dump(log_dict, log_file)
-    log_file.close()
-    # but also save the arguments
-    # pretty sure you need to open it binary for pickling
-    log_file = open(args_file_path, 'wb')
-    pickle.dump(args, log_file)
-    log_file.close()
-
-
-def cleanUpRun(log_dict, final_iteration, n_iterations_you_want):
-    # shave off the zeros at the end
-    for key in log_dict:
-        log_dict[key] = log_dict[key][:final_iteration]
-    # and now keep only every nth iteration
-    # because you don't want to plot too much
-    if final_iteration > n_iterations_you_want:
-        nth_to_keep = final_iteration // n_iterations_you_want
-        bool_array = [i % nth_to_keep == 0 for i in range(final_iteration)]
-        # actual final number
-        # True is turned to 0, False to 0, praised by python and its ways
-        n_iters = np.sum(bool_array)
-        for key in log_dict:
-            log_dict[key] = log_dict[key][bool_array]
-
-    return log_dict
-
-
-def loadRunForAnalysis(log_data_file_name, args_file_name):
-    log_data_file = open(log_data_file_name, 'rb')
-    args_file = open(args_file_name, 'rb')
-    log_data = pickle.load(log_data_file)
-    args = pickle.load(args_file)
-    # if you're analyzing, you're not running anything on the real robot
-    args.simulation = True
-    args.pinocchio_only = True
-    return log_data, args
+
+class LogManager:
+    """
+    LogManager
+    ----------
+    The simplest possible way to combine logs of different 
+    control loops - store them separately.
+    Comes with some functions to clean and combine logs
+    of different control loops (TODO).
+    - input: log_dicts from ControlLoopManager
+    - output: saves this whole class as pickle -
+              data and arguments included
+    """
+    def __init__(self, args):
+        if args is None:
+            return
+        self.args = args
+        self.loop_logs = {}
+        self.loop_number = 0
+        # name the run
+        self.run_name = 'latest_run'
+        if self.args.run_name != 'none':
+            self.run_name = self.args.run_name
+        # assign save directory
+        if args.save_dir != "./data":
+            if os.path.exists(self.args.save_dir):
+                self.save_dir = self.args.save_dir 
+        else:
+            if os.path.exists("./data"):
+                self.save_dir = "./data"
+            else:
+                os.makedirs('/tmp/data', exist_ok=True)
+                self.save_dir = '/tmp/data'
+            
+        # if indexing (same run, multiple times, want to save all) 
+        # update the name with the free index
+        if args.index_runs:
+            index = self.findLatestIndex()
+            self.run_name = self.run_name + "_" + str(index) + ".pickle"
+
+        self.save_file_path = os.path.join(self.save_dir, self.run_name)
+
+
+    def storeControlLoopRun(self, log_dict, loop_name, final_iteration):
+        loop_name = str(self.loop_number) + '_' + loop_name
+        self.loop_number += 1
+        self.loop_logs[loop_name] = log_dict
+
+
+    def saveLog(self, cleanUpRun=False):
+        """
+        saveLog
+        -------
+        transforms the logs obtained from control loops
+        into numpy arrays and pickles the whole of LogManager
+        (including the data and the args).
+        Uses default pickling.
+        """
+        # finally transfer to numpy (after nothing is running anymore)
+        for control_loop_name in self.loop_logs:
+            for key in self.loop_logs[control_loop_name]:
+                self.loop_logs[control_loop_name][key] = np.array(self.loop_logs[control_loop_name][key])
+        if cleanUpRun:
+            self.cleanUpRun()
+        log_file = open(self.save_file_path, 'wb')
+        pickle.dump(self.__dict__, log_file)
+        log_file.close()
+
+    def loadLog(self, save_file_path):
+        """
+        loadLog
+        -------
+        unpickles a log, which is the whole of LogManager
+        (including the data and the args).
+        Uses default (un)pickling.
+        """
+        log_file = open(save_file_path, 'rb')
+        tmp_dict = pickle.load(log_file)
+        log_file.close()
+        self.__dict__.clear()
+        self.__dict__.update(tmp_dict)
+
+
+    def findLatestIndex(self):
+        """
+        findLatestIndex
+        ---------------
+        reads save_dir, searches for run_name,
+        finds the highest index within the file whose names match run_name.
+        NOTE: better to not have other files in the data dir,
+        this isn't written to work in every circumstances,
+        it assumes a directory with Simple Manipulator Control log files only
+        """
+        child = subprocess.Popen(['ls', self.save_dir], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+        files_in_datadir = child.stdout.read().decode('utf-8').split("\n")
+        regex_name = re.compile(self.run_name + ".*") 
+        regex_index = re.compile("[0-9]+") 
+        highest_index = -1
+        for file_name in files_in_datadir:
+            rez_name = regex_name.search(file_name)
+            if rez_name != None:
+                rez_index = regex_index.findall(file_name)
+                if len(rez_index) > 0:
+                    this_file_name_index = int(rez_index[-1])
+                    if this_file_name_index > highest_index:
+                        highest_index = this_file_name_index
+
+        index = highest_index + 1
+        return index
diff --git a/python/ur_simple_control/util/map2DPathTo3DPlane.py b/python/ur_simple_control/util/map2DPathTo3DPlane.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0d9f78b3f870b612ad099ce543787c112480505
--- /dev/null
+++ b/python/ur_simple_control/util/map2DPathTo3DPlane.py
@@ -0,0 +1,34 @@
+import numpy as np
+
+#######################################################################
+#                    map the pixels to a 3D plane                     #
+#######################################################################
+def map2DPathTo3DPlane(path_2D, width, height):
+    """
+    map2DPathTo3DPlane
+    --------------------
+    TODO: THINK AND FINALIZE THE FRAME
+    TODO: WRITE PRINT ABOUT THE FRAME TO THE USER
+    assumptions:
+    - origin in top-left corner (natual for western latin script writing)
+    - x goes right (from TCP)
+    - z will go away from the board
+    - y just completes the right-hand frame
+    TODO: RIGHT NOW we don't have a right-handed frame lmao, change that where it should be
+    NOTE: this function as well be in the util or drawing file, but whatever for now, it will be done
+          once it will actually be needed elsewhere
+    Returns a 3D path appropriately scaled, and placed into the first quadrant
+    of the x-y plane of the body-frame (TODO: what is the body frame if we're general?)
+    """
+    z = np.zeros((len(path_2D),1))
+    path_3D = np.hstack((path_2D,z))
+    # scale the path to m
+    path_3D[:,0] = path_3D[:,0] * width
+    path_3D[:,1] = path_3D[:,1] * height
+    # in the new coordinate system we're going in the -y direction
+    # TODO this is a demo specific hack, 
+    # make it general for a future release
+    path_3D[:,1] = -1 * path_3D[:,1] + height
+    return path_3D
+
+
diff --git a/python/ur_simple_control/visualize/__pycache__/visualize.cpython-312.pyc b/python/ur_simple_control/visualize/__pycache__/visualize.cpython-312.pyc
index 8a1adf2f8db4ef94087d08a06981bcfb331508e7..8fcb405e56bc736d66c541ba4caf5b5ff6f02c36 100644
Binary files a/python/ur_simple_control/visualize/__pycache__/visualize.cpython-312.pyc and b/python/ur_simple_control/visualize/__pycache__/visualize.cpython-312.pyc differ
diff --git a/python/ur_simple_control/visualize/manipulator_comparison_visualizer.py b/python/ur_simple_control/visualize/manipulator_comparison_visualizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..3dd8012350d72e63bb632eeebc78f9c33e8aecea
--- /dev/null
+++ b/python/ur_simple_control/visualize/manipulator_comparison_visualizer.py
@@ -0,0 +1,113 @@
+import numpy as np
+import argparse
+import os
+import time
+from itertools import zip_longest
+from multiprocessing import Process, Queue
+from ur_simple_control.managers import getMinimalArgParser, RobotManager
+from ur_simple_control.util.logging_utils import LogManager
+from ur_simple_control.visualize.visualize import manipulatorComparisonVisualizer
+
+def getLogComparisonArgs():
+    parser = getMinimalArgParser()
+#    parser = getClikArgs(parser)
+#    parser = getDMPArgs(parser)
+#    parser = getBoardCalibrationArgs(parser)
+    parser.add_argument('--log-file1', type=str, \
+            help="first log file to load for comparison")
+    parser.add_argument('--log-file2', type=str, \
+            help="first log file to load for comparison")
+    args = parser.parse_args()
+    # these are obligatory
+    args.visualize_manipulator = False
+    args.pinocchio_only = True
+    args.simulation = False
+    return args
+
+class ManipulatorComparisonManager:
+    def __init__(self, args):
+        self.args = args
+        # these are obligatory
+        args.visualize_manipulator = False
+        args.pinocchio_only = True
+        args.simulation = False
+
+        self.robot1 = RobotManager(args)
+        self.robot2 = RobotManager(args)
+
+        # no two loops will have the same amount of timesteps.
+        # we need to store the last available step for both robots.
+        self.lastq1 = np.zeros(self.robot1.model.nq)
+        self.lastq2 = np.zeros(self.robot2.model.nq)
+
+        if os.path.exists(args.log_file1):
+            self.logm1 = LogManager(None)
+            self.logm1.loadLog(args.log_file1)
+        else:
+            print("you did not give me a valid path, exiting")
+            exit()
+        if os.path.exists(args.log_file2):
+            self.logm2 = LogManager(None)
+            self.logm2.loadLog(args.log_file2)
+        else:
+            print("you did not give me a valid path, exiting")
+            exit()
+
+        self.manipulator_visualizer_queue = Queue()
+
+        # we are assuming both robots are the same,
+        # but this does not necessarily have to be true
+        self.manipulator_visualizer_process = Process(target=manipulatorComparisonVisualizer, 
+                                                 args=(args, self.robot1.model, self.robot1.collision_model, 
+                                                       self.robot2.visual_model, self.manipulator_visualizer_queue, ))
+        if self.args.debug_prints:
+            print("MANIPULATOR_COMPARISON_VISUALIZER: manipulator_comparison_visualizer_process started")
+        self.manipulator_visualizer_process.start()
+        # wait for meshcat to start
+        self.manipulator_visualizer_queue.put((np.zeros(self.robot1.model.nq), np.ones(self.robot2.model.nq)))
+        if self.args.debug_prints:
+            print("COMPARE_LOGS_MAIN: i managed to put initializing (q1, q2) to manipulator_comparison_visualizer_queue")
+        # wait until it's ready (otherwise you miss half the sim potentially)
+        # 5 seconds should be more than enough,
+        # and i don't want to complicate this code by complicating IPC
+        time.sleep(5)
+
+    # NOTE: this uses slightly fancy python to make it bareable to code
+    # NOTE: dict keys are GUARANTEED to be in insert order from python 3.7 onward
+    def visualizeManipulatorRuns(self):
+        for control_loop1, control_loop2 in zip_longest(self.logm1.loop_logs, self.logm2.loop_logs):
+            print(f'run {self.logm1.args.run_name}, controller: {control_loop1}')
+            print(f'run {self.logm2.args.run_name}, controller: {control_loop2}')
+            # relying on python's default thing.toBool()
+            if not control_loop1:
+                q1 = self.lastq1
+                for q2 in self.logm2.loop_logs[control_loop2]['qs']:
+                    self.manipulator_visualizer_queue.put_nowait((q1, q2))
+            if not control_loop2:
+                q2 = self.lastq2
+                for q1 in self.logm1.loop_logs[control_loop1]['qs']:
+                    self.manipulator_visualizer_queue.put_nowait((q1, q2))
+            if control_loop1 and control_loop2:
+                for q1, q2 in zip_longest(self.logm1.loop_logs[control_loop1]['qs'], \
+                            self.logm2.loop_logs[control_loop2]['qs']):
+                    if not (q1 is None):
+                        self.lastq1 = q1
+                    if not (q2 is None):
+                        self.lastq2 = q2
+                    print(self.lastq1)
+                    print(self.lastq2)
+                    self.manipulator_visualizer_queue.put_nowait((self.lastq1, self.lastq2))
+
+
+
+if __name__ == "__main__":
+    args = getLogComparisonArgs()
+    visualizer = ManipulatorComparisonManager(args)
+    visualizer.visualizeManipulatorRuns()
+    time.sleep(100)
+    visualizer.manipulator_visualizer_queue.put("befree")
+    print("main done")
+    time.sleep(0.1)
+    visualizer.manipulator_visualizer_process.terminate()
+    if args.debug_prints:
+        print("terminated manipulator_visualizer_process")
diff --git a/python/ur_simple_control/visualize/manipulator_visual_motion_analyzer.py b/python/ur_simple_control/visualize/manipulator_visual_motion_analyzer.py
index 280709b4b2a5feab1e5225782403661bc60dacec..61182930c12379c14d7521597232f02cf33ca755 100644
--- a/python/ur_simple_control/visualize/manipulator_visual_motion_analyzer.py
+++ b/python/ur_simple_control/visualize/manipulator_visual_motion_analyzer.py
@@ -68,23 +68,23 @@ def getController(controller_name):
 
     return invKinm_dampedSquares
 
-"""
-GetCommandThread
------------------
-- NOTE: NOT USED ATM
-- requires separate thread to accomodate the updating
-  weirdness of tkinter. 
-- i know how this works, so i'm running with it,
-  if you want to make it better, be my guest, code it up
-- TODO: make it work for the new application
---> just send q commands here
-- there are 2 queues because i didn't know what i was doing honestly,
-  but hey, it works and it ain't hurting nobody
-- the point is that you generate an event for the gui, and then get what
-  that event actually was by poping from the queue.
-  and you can shove anything, ex. whole dicts into the queue because python
-"""
 class GetCommandThread(threading.Thread):
+    """
+    GetCommandThread
+    -----------------
+    - NOTE: NOT USED ATM
+    - requires separate thread to accomodate the updating
+      weirdness of tkinter. 
+    - i know how this works, so i'm running with it,
+      if you want to make it better, be my guest, code it up
+    - TODO: make it work for the new application
+    --> just send q commands here
+    - there are 2 queues because i didn't know what i was doing honestly,
+      but hey, it works and it ain't hurting nobody
+    - the point is that you generate an event for the gui, and then get what
+      that event actually was by poping from the queue.
+      and you can shove anything, ex. whole dicts into the queue because python
+    """
     def __init__(self, queue, localQueue, _check):
         super(GetCommandThread, self).__init__()
         self.queue = queue
@@ -110,15 +110,6 @@ class GetCommandThread(threading.Thread):
         #    print('registered new callback')
 
 
-"""
-ManipulatorVisualMotionAnalyzer
-----------------------------------
-- for now leaving run generation here for easier testing
-- later load run and run on that
-- add option to load run while the robot is running
-- some possibly unused stuff will be added here as a weird transplant
-  from an old project, but will be trimmed later
-"""
 # shove artists into dicts, not lists,
 # so that they have names. the for loop functions the same way,
 # but you get to know what you have, which might be useful later.
@@ -127,6 +118,15 @@ ManipulatorVisualMotionAnalyzer
 # this list is to be updated only if you load a new run.
 # or even skip this and reload everything, who cares.
 class ManipulatorVisualMotionAnalyzer:
+    """
+    ManipulatorVisualMotionAnalyzer
+    ----------------------------------
+    - for now leaving run generation here for easier testing
+    - later load run and run on that
+    - add option to load run while the robot is running
+    - some possibly unused stuff will be added here as a weird transplant
+      from an old project, but will be trimmed later
+    """
     def __init__(self, root, queue, data, real_time_flag, **kwargs):
         # need to put this in the main program, 
         # so you need to pass it here to use it
diff --git a/python/ur_simple_control/visualize/visualize.py b/python/ur_simple_control/visualize/visualize.py
index 801013b6d49c69f251055e50e80386920fa7acc7..93f4a1347b63b71d99f7f3fc85156c4ff7dd01ea 100644
--- a/python/ur_simple_control/visualize/visualize.py
+++ b/python/ur_simple_control/visualize/visualize.py
@@ -190,7 +190,6 @@ def manipulatorVisualizer(args, model, collision_model, visual_model, queue):
                 if q == "befree":
                     if args.debug_prints:
                         print("MANIPULATORVISUALIZER: got befree, manipulatorVisualizer out")
-                    # TODO: find a way to actually close it, i don't want a bilion dangling sockets
                     viz.viewer.window.server_proc.kill()
                     viz.viewer.window.server_proc.wait()
                     break
@@ -198,8 +197,63 @@ def manipulatorVisualizer(args, model, collision_model, visual_model, queue):
     except KeyboardInterrupt:
         if args.debug_prints:
             print("MANIPULATORVISUALIZER: caught KeyboardInterrupt, i'm out")
-        # TODO: find a way to actually close it, i don't want a bilion dangling sockets
-        # and a random explosion caused by them
-        #viz.viewer.close()
         viz.viewer.window.server_proc.kill()
         viz.viewer.window.server_proc.wait()
+
+# could be merged with the above function.
+# but they're different enough in usage to warrent a different function,
+# instead of polluting the above one with ifs
+def manipulatorComparisonVisualizer(args, model, collision_model, visual_model, queue):
+    # for whatever reason the hand-e files don't have/
+    # meshcat can't read scaling information.
+    # so we scale manually
+    for geom in visual_model.geometryObjects:
+        if "hand" in geom.name:
+            s = geom.meshScale
+            # this looks exactly correct lmao
+            s *= 0.001
+            geom.meshScale = s
+    for geom in collision_model.geometryObjects:
+        if "hand" in geom.name:
+            s = geom.meshScale
+            # this looks exactly correct lmao
+            s *= 0.001
+            geom.meshScale = s
+    viz = MeshcatVisualizer(model, collision_model, visual_model)
+    viz.initViewer(open=True)
+    # load the first one
+    viz.loadViewerModel()
+    # maybe needed
+    #viz.displayVisuals(True)
+
+    # other robot display
+    viz2 = MeshcatVisualizer(model, collision_model, visual_model)
+    viz2.initViewer(viz.viewer)
+    # i don't know if rootNodeName does anything apart from being different
+    viz2.loadViewerModel(rootNodeName="pinocchio2")
+    # initialize
+    q1, q2 = queue.get()
+    viz.display(q1)
+    viz2.display(q2)
+
+    print("MANIPULATOR_COMPARISON_VISUALIZER: FULLY ONLINE")
+    try:
+        while True:
+            q = queue.get()
+            if type(q) == str:
+                print("got str q")
+                if q == "befree":
+                    if args.debug_prints:
+                        print("MANIPULATOR_COMPARISON_VISUALIZER: got befree, manipulatorComparisonVisualizer out")
+                    viz.viewer.window.server_proc.kill()
+                    viz.viewer.window.server_proc.wait()
+                    break
+            q1, q2 = q
+            viz.display(q1)
+            viz2.display(q2)
+    except KeyboardInterrupt:
+        if args.debug_prints:
+            print("MANIPULATOR_COMPARISON_VISUALIZER: caught KeyboardInterrupt, i'm out")
+        viz.viewer.window.server_proc.kill()
+        viz.viewer.window.server_proc.wait()
+