From f16f02c6dc97ba394e504706a31cd34276925356 Mon Sep 17 00:00:00 2001 From: Anubhav Harsh Sinha Date: Tue, 22 Sep 2026 20:22:47 +0530 Subject: [PATCH 1/5] fix: pad exported RoboticArm rows to four servos The CSV header always lists Servo1-Servo4, but each row only held one value per servo on the arm, so a timeline exported from a 2-servo arm had short rows that import_timeline_from_csv could not read. --- pslab/external/motor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pslab/external/motor.py b/pslab/external/motor.py index 1fba065..9044b1f 100644 --- a/pslab/external/motor.py +++ b/pslab/external/motor.py @@ -165,5 +165,7 @@ def export_timeline_to_csv( writer = csv.writer(csvfile) writer.writerow(["Timestep", "Servo1", "Servo2", "Servo3", "Servo4"]) for i, row in enumerate(timeline): + # Pad to four servos so every row matches the header. + row = list(row) + [None] * (RoboticArm.MAX_SERVOS - len(row)) pos = ["null" if val is None else val for val in row] writer.writerow([i] + pos) From 82d85009ec6eb6bb1651aa797210200bc060aa31 Mon Sep 17 00:00:00 2001 From: Anubhav Harsh Sinha Date: Tue, 22 Sep 2026 20:23:15 +0530 Subject: [PATCH 2/5] fix: import one angle per servo in RoboticArm timelines import_timeline_from_csv always returned four angles per timestep, but run_schedule requires exactly one per servo, so a 1-3 servo arm could not run a timeline it imported. Return angles for this arm's servos only, reject files that set an angle for a servo the arm lacks, and treat values missing from short rows as unset. --- pslab/external/motor.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pslab/external/motor.py b/pslab/external/motor.py index 9044b1f..879b4ed 100644 --- a/pslab/external/motor.py +++ b/pslab/external/motor.py @@ -125,7 +125,8 @@ def import_timeline_from_csv(self, filepath: str) -> List[List[int]]: Returns ------- List[List[int]] - A timeline consisting of servo angle values per timestep. + A timeline consisting of servo angle values per timestep, with one + angle per servo of this arm. """ timeline = [] @@ -133,13 +134,18 @@ def import_timeline_from_csv(self, filepath: str) -> List[List[int]]: reader = csv.DictReader(csvfile) for row in reader: angles = [] - for key in ["Servo1", "Servo2", "Servo3", "Servo4"]: - value = row[key] - if value == "null": + for i in range(1, RoboticArm.MAX_SERVOS + 1): + value = row.get(f"Servo{i}") + # Short rows from older exports leave trailing servos unset. + if value in (None, "", "null"): angles.append(None) else: angles.append(int(value)) - timeline.append(angles) + if any(angle is not None for angle in angles[len(self.servos) :]): + raise ValueError( + f"Timeline sets angles for more than {len(self.servos)} servos" + ) + timeline.append(angles[: len(self.servos)]) return timeline From 341dba3ca4ddcd633fabd4993e925708dc165767 Mon Sep 17 00:00:00 2001 From: Anubhav Harsh Sinha Date: Tue, 22 Sep 2026 20:23:24 +0530 Subject: [PATCH 3/5] test: cover RoboticArm CSV round trips without hardware Exercise export/import for 1-4 servo arms, running an imported timeline, short rows, and rejecting angles for servos the arm lacks. --- tests/test_robotic_arm.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/test_robotic_arm.py diff --git a/tests/test_robotic_arm.py b/tests/test_robotic_arm.py new file mode 100644 index 0000000..8b522f4 --- /dev/null +++ b/tests/test_robotic_arm.py @@ -0,0 +1,64 @@ +"""Tests for pslab.external.motor.RoboticArm CSV import and export. + +These tests do not require a connected PSLab. +""" + +from unittest.mock import MagicMock + +import pytest + +from pslab.external.motor import RoboticArm, Servo + + +def make_arm(servo_count: int) -> RoboticArm: + pwm = MagicMock() + return RoboticArm([Servo(f"SQ{i + 1}", pwm) for i in range(servo_count)]) + + +def write_csv(path, rows): + header = "Timestep,Servo1,Servo2,Servo3,Servo4\n" + path.write_text(header + "".join(f"{row}\n" for row in rows)) + return str(path) + + +@pytest.mark.parametrize("servo_count", [1, 2, 3, 4]) +def test_export_import_round_trip(tmp_path, servo_count): + arm = make_arm(servo_count) + timeline = [ + [10 * (i + 1) for i in range(servo_count)], + [None] + [90] * (servo_count - 1), + ] + + arm.export_timeline_to_csv(timeline, str(tmp_path)) + (exported,) = tmp_path.glob("*.csv") + + assert arm.import_timeline_from_csv(str(exported)) == timeline + + +def test_export_pads_rows_to_four_servos(tmp_path): + make_arm(2).export_timeline_to_csv([[10, 20]], str(tmp_path)) + (exported,) = tmp_path.glob("*.csv") + + assert exported.read_text().splitlines()[1] == "0,10,20,null,null" + + +def test_imported_timeline_runs_on_a_smaller_arm(tmp_path): + arm = make_arm(2) + path = write_csv(tmp_path / "t.csv", ["0,10,20,null,null"]) + + arm.run_schedule(arm.import_timeline_from_csv(path), time_step=0) + + assert [servo.angle for servo in arm.servos] == [10, 20] + + +def test_import_accepts_short_rows(tmp_path): + path = write_csv(tmp_path / "t.csv", ["0,10,20"]) + + assert make_arm(2).import_timeline_from_csv(path) == [[10, 20]] + + +def test_import_rejects_angles_for_missing_servos(tmp_path): + path = write_csv(tmp_path / "t.csv", ["0,10,20,30,null"]) + + with pytest.raises(ValueError, match="more than 2 servos"): + make_arm(2).import_timeline_from_csv(path) From 57bd58d820c9b2df609256dac676867abf9d0dac Mon Sep 17 00:00:00 2001 From: Anubhav Harsh Sinha Date: Tue, 22 Sep 2026 20:50:36 +0530 Subject: [PATCH 4/5] fix: reject malformed RoboticArm CSV input and output Importing now requires the Servo1-Servo4 header columns instead of treating a missing column like an unset angle, and exporting rejects timesteps with more than four angles before creating the file. --- pslab/external/motor.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pslab/external/motor.py b/pslab/external/motor.py index 879b4ed..d77e839 100644 --- a/pslab/external/motor.py +++ b/pslab/external/motor.py @@ -132,10 +132,15 @@ def import_timeline_from_csv(self, filepath: str) -> List[List[int]]: with open(filepath, mode="r", newline="") as csvfile: reader = csv.DictReader(csvfile) + columns = [f"Servo{i}" for i in range(1, RoboticArm.MAX_SERVOS + 1)] + if reader.fieldnames is None or any( + column not in reader.fieldnames for column in columns + ): + raise ValueError("CSV must contain the Servo1-Servo4 columns") for row in reader: angles = [] - for i in range(1, RoboticArm.MAX_SERVOS + 1): - value = row.get(f"Servo{i}") + for column in columns: + value = row.get(column) # Short rows from older exports leave trailing servos unset. if value in (None, "", "null"): angles.append(None) @@ -163,6 +168,12 @@ def export_timeline_to_csv( Directory path where the CSV file will be saved. The filename will include a timestamp to ensure uniqueness. """ + for i, row in enumerate(timeline): + if len(row) > RoboticArm.MAX_SERVOS: + raise ValueError( + f"Timestep {i} has more than {RoboticArm.MAX_SERVOS} angles" + ) + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"Robotic_Arm{timestamp}.csv" filepath = os.path.join(folderpath, filename) From 7e25a47a675c7143351d47c4b8e689ded3145b2a Mon Sep 17 00:00:00 2001 From: Anubhav Harsh Sinha Date: Tue, 22 Sep 2026 20:50:36 +0530 Subject: [PATCH 5/5] test: cover rejected RoboticArm CSV input and output --- tests/test_robotic_arm.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_robotic_arm.py b/tests/test_robotic_arm.py index 8b522f4..815440e 100644 --- a/tests/test_robotic_arm.py +++ b/tests/test_robotic_arm.py @@ -62,3 +62,18 @@ def test_import_rejects_angles_for_missing_servos(tmp_path): with pytest.raises(ValueError, match="more than 2 servos"): make_arm(2).import_timeline_from_csv(path) + + +def test_import_rejects_missing_servo_columns(tmp_path): + path = tmp_path / "t.csv" + path.write_text("Timestep,Servo1,Servo2\n0,10,20\n") + + with pytest.raises(ValueError, match="Servo1-Servo4"): + make_arm(2).import_timeline_from_csv(str(path)) + + +def test_export_rejects_more_than_four_angles(tmp_path): + with pytest.raises(ValueError, match="more than 4 angles"): + make_arm(4).export_timeline_to_csv([[1, 2, 3, 4, 5]], str(tmp_path)) + + assert list(tmp_path.iterdir()) == []