Skip to content

Commit 30ff45d

Browse files
committed
Add minimal experiment tutorial example
1 parent 6e44eec commit 30ff45d

7 files changed

Lines changed: 656 additions & 0 deletions

File tree

docs/source/user_guide.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ are still in planning or early development.
3333

3434
user_guide_first_run
3535
user_guide_sample_task
36+
user_guide_minimal_experiment
3637
user_guide_outputs
3738
user_guide_troubleshooting
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
Minimum Experiment Code
2+
=======================
3+
4+
Why This Example Exists
5+
-----------------------
6+
7+
After you run the sample task once, the next useful question is usually:
8+
9+
``What is the smallest amount of code I need to write my own experiment?``
10+
11+
The answer is **not** "put everything in one script and call random BehavBox
12+
methods directly." That would be shorter, but it would teach the wrong pattern.
13+
14+
The supported minimum pattern is:
15+
16+
1. one small task module containing only task logic
17+
2. one runner script for local mock use
18+
3. one runner script for a headless Raspberry Pi (RPi) over Secure Shell (SSH)
19+
4. one small session-configuration helper
20+
21+
This keeps the code small while still using the current supported lifecycle.
22+
23+
Where The Example Lives
24+
-----------------------
25+
26+
The tracked example files are:
27+
28+
- ``sample_tasks/minimal_experiment/task.py``
29+
- ``sample_tasks/minimal_experiment/session_config.py``
30+
- ``sample_tasks/minimal_experiment/run_mock.py``
31+
- ``sample_tasks/minimal_experiment/run_pi.py``
32+
33+
The Shared Session Configuration
34+
--------------------------------
35+
36+
The session helper builds the ``session_info`` dictionary in one place:
37+
38+
.. literalinclude:: ../../sample_tasks/minimal_experiment/session_config.py
39+
:language: python
40+
:lines: 1-67
41+
42+
Why it is written this way:
43+
44+
- output paths are centralized
45+
- the mock and headless-Pi versions stay consistent
46+
- the task code does not need to know where files should be written
47+
- the only real difference between the two modes is whether audio stays mocked
48+
49+
The Minimal Task
50+
----------------
51+
52+
The task module contains the experiment logic itself:
53+
54+
.. literalinclude:: ../../sample_tasks/minimal_experiment/task.py
55+
:language: python
56+
57+
What this task does:
58+
59+
- plays one cue
60+
- opens one response window
61+
- watches for one response event
62+
- optionally delivers reward
63+
- stops cleanly after a response or after the response window expires
64+
65+
Why it is written this way:
66+
67+
- ``prepare_task()`` holds setup that belongs to the task, not to the runner
68+
- ``start_task()`` starts the experiment by entering the first phase
69+
- ``handle_event()`` reacts only to relevant input events
70+
- ``update_task()`` advances the finite state machine (FSM) based on time
71+
- ``finalize_task()`` returns a small summary that becomes
72+
``final_task_state.json``
73+
74+
This is the smallest useful pattern that still matches the current task API.
75+
76+
Run It Locally In Mock Mode
77+
---------------------------
78+
79+
Use this version on your local machine when you want the browser mock user
80+
interface (UI).
81+
82+
The runner code is:
83+
84+
.. literalinclude:: ../../sample_tasks/minimal_experiment/run_mock.py
85+
:language: python
86+
87+
Run it with:
88+
89+
.. code-block:: bash
90+
91+
cd /Users/lukesjulson/codex/RPi4_refactor/targets/RPi4_behavior_boxes_hardware
92+
uv run python -m sample_tasks.minimal_experiment.run_mock --session-tag tutorial_minimal
93+
94+
What is specific to local mock mode:
95+
96+
- it forces mock mode with ``BEHAVBOX_FORCE_MOCK=1``
97+
- it starts the browser mock UI automatically
98+
- it tells you to pulse ``lick_3`` in the browser
99+
100+
This is the right choice when:
101+
102+
- you are working on a desktop or laptop
103+
- you want to test task flow without real hardware
104+
- you want to debug the task logic first
105+
106+
Run It On A Headless Pi Over SSH
107+
--------------------------------
108+
109+
Use this version when you are logged into a real box over SSH and want the
110+
responses to come from the physical hardware.
111+
112+
The runner code is:
113+
114+
.. literalinclude:: ../../sample_tasks/minimal_experiment/run_pi.py
115+
:language: python
116+
117+
Example command on the Pi:
118+
119+
.. code-block:: bash
120+
121+
cd ~/behavbox/RPi_behavior_boxes_hardware
122+
uv run python -m sample_tasks.minimal_experiment.run_pi \
123+
--output-root ~/behavbox_runs \
124+
--session-tag tutorial_minimal
125+
126+
What is specific to the headless-Pi version:
127+
128+
- it does **not** force mock mode
129+
- it disables automatic mock-UI startup
130+
- it assumes inputs come from the real box
131+
132+
This is the right choice when:
133+
134+
- you are connected to a headless Pi over SSH
135+
- you want a real hardware run
136+
- you do not expect a local browser control panel to appear automatically
137+
138+
Why There Are Two Runner Files
139+
------------------------------
140+
141+
This split is intentional.
142+
143+
The mock and headless-Pi versions should not be hidden behind one opaque flag,
144+
because users need to understand which environment they are running in:
145+
146+
- local mock mode is for safe local testing with a browser UI
147+
- headless Pi mode is for real box execution over SSH
148+
149+
If those modes are mixed together carelessly, users end up not knowing whether
150+
they are testing their code or their hardware.
151+
152+
What To Copy For Your Own Task
153+
------------------------------
154+
155+
If you want to start a new task, copy these pieces:
156+
157+
1. ``task.py``
158+
2. ``session_config.py``
159+
3. one runner script that matches how you plan to work
160+
161+
Then change only the task logic first:
162+
163+
- cue selection
164+
- response event
165+
- stop condition
166+
- reward rule
167+
168+
Do **not** start by rewriting the runner or bypassing ``TaskRunner`` unless you
169+
have a clear reason. The runner is what gives you:
170+
171+
- consistent startup and shutdown
172+
- standard task artifacts
173+
- a path that still matches the current hardware repo architecture
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Minimal lifecycle-based experiment example for end-user tutorials."""
2+
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Run the minimal tutorial experiment locally in mock mode."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import os
7+
from pathlib import Path
8+
9+
from box_runtime.behavior.behavbox import BehavBox
10+
from box_runtime.mock_hw.server import ensure_server_running
11+
from sample_tasks.common.runner import TaskRunner
12+
from sample_tasks.minimal_experiment.session_config import build_mock_session_info
13+
from sample_tasks.minimal_experiment import task as minimal_task
14+
15+
16+
def configure_mock_environment() -> None:
17+
"""Configure environment variables for local mock-mode tutorial runs."""
18+
19+
os.environ["BEHAVBOX_FORCE_MOCK"] = "1"
20+
os.environ["BEHAVBOX_MOCK_UI_AUTOSTART"] = "1"
21+
22+
23+
def main() -> int:
24+
"""Run the minimal experiment locally with the browser mock UI.
25+
26+
Returns:
27+
- ``exit_code``: zero on clean completion
28+
"""
29+
30+
parser = argparse.ArgumentParser(description="Run the minimal experiment locally in mock mode.")
31+
parser.add_argument("--output-root", default="tmp_task_runs", help="Directory root for task outputs.")
32+
parser.add_argument("--session-tag", default="minimal_experiment_session", help="Basename for the session directory.")
33+
parser.add_argument("--max-duration-s", type=float, default=30.0, help="Maximum session duration in seconds.")
34+
parser.add_argument("--reward-on-response", action="store_true", help="Deliver reward when the response event is detected.")
35+
args = parser.parse_args()
36+
37+
configure_mock_environment()
38+
39+
output_root = Path(args.output_root).resolve()
40+
output_root.mkdir(parents=True, exist_ok=True)
41+
session_info = build_mock_session_info(output_root, args.session_tag)
42+
mock_url = ensure_server_running()
43+
print(f"Mock hardware UI: {mock_url}")
44+
print("This is the local mock workflow. Open the browser UI and pulse lick_3 to respond.")
45+
46+
runner = TaskRunner(
47+
box=BehavBox(session_info),
48+
task=minimal_task,
49+
task_config={
50+
"max_duration_s": float(args.max_duration_s),
51+
"reward_on_response": bool(args.reward_on_response),
52+
},
53+
)
54+
55+
try:
56+
final_state = runner.run()
57+
except KeyboardInterrupt:
58+
runner.stop(reason="keyboard_interrupt")
59+
final_state = runner.finalize()
60+
61+
print(f"Final task state written to: {Path(session_info['dir_name']) / 'final_task_state.json'}")
62+
print(final_state)
63+
return 0
64+
65+
66+
if __name__ == "__main__":
67+
raise SystemExit(main())
68+
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Run the minimal tutorial experiment on a headless Raspberry Pi over SSH."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import os
7+
from pathlib import Path
8+
9+
from box_runtime.behavior.behavbox import BehavBox
10+
from sample_tasks.common.runner import TaskRunner
11+
from sample_tasks.minimal_experiment.session_config import build_headless_pi_session_info
12+
from sample_tasks.minimal_experiment import task as minimal_task
13+
14+
15+
def configure_headless_pi_environment() -> None:
16+
"""Configure environment variables for a real headless Pi run.
17+
18+
The real Pi runner should not force mock mode. It also should not try to
19+
auto-start the browser mock UI.
20+
"""
21+
22+
os.environ.pop("BEHAVBOX_FORCE_MOCK", None)
23+
os.environ["BEHAVBOX_MOCK_UI_AUTOSTART"] = "0"
24+
25+
26+
def main() -> int:
27+
"""Run the minimal experiment on a headless Raspberry Pi.
28+
29+
Returns:
30+
- ``exit_code``: zero on clean completion
31+
"""
32+
33+
parser = argparse.ArgumentParser(description="Run the minimal experiment on a headless Raspberry Pi.")
34+
parser.add_argument("--output-root", default="~/behavbox_runs", help="Directory root for task outputs on the Pi.")
35+
parser.add_argument("--session-tag", default="minimal_experiment_session", help="Basename for the session directory.")
36+
parser.add_argument("--max-duration-s", type=float, default=30.0, help="Maximum session duration in seconds.")
37+
parser.add_argument("--reward-on-response", action="store_true", help="Deliver reward when the response event is detected.")
38+
args = parser.parse_args()
39+
40+
configure_headless_pi_environment()
41+
42+
output_root = Path(args.output_root).expanduser().resolve()
43+
output_root.mkdir(parents=True, exist_ok=True)
44+
session_info = build_headless_pi_session_info(output_root, args.session_tag)
45+
print("This is the headless Raspberry Pi workflow.")
46+
print("Run it over SSH. Responses come from the physical box inputs, not the browser mock UI.")
47+
48+
runner = TaskRunner(
49+
box=BehavBox(session_info),
50+
task=minimal_task,
51+
task_config={
52+
"max_duration_s": float(args.max_duration_s),
53+
"reward_on_response": bool(args.reward_on_response),
54+
},
55+
)
56+
57+
try:
58+
final_state = runner.run()
59+
except KeyboardInterrupt:
60+
runner.stop(reason="keyboard_interrupt")
61+
final_state = runner.finalize()
62+
63+
print(f"Final task state written to: {Path(session_info['dir_name']) / 'final_task_state.json'}")
64+
print(final_state)
65+
return 0
66+
67+
68+
if __name__ == "__main__":
69+
raise SystemExit(main())

0 commit comments

Comments
 (0)