diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..997504b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# SCM syntax highlighting & preventing 3-way merges +pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff diff --git a/examples/all_on.py b/examples/all_on.py new file mode 100644 index 0000000..5ddd4df --- /dev/null +++ b/examples/all_on.py @@ -0,0 +1,16 @@ +import time + +from arena_interface import ArenaInterface + +ai = ArenaInterface(debug=True) +ai.set_ethernet_mode(ip_address="10.103.40.45") + +start_time = time.time() +ai.all_on() +end_time = time.time() +duration = end_time - start_time + +time.sleep(5) +ai.all_off() + +print(f"Duration of all_on() call: {duration:.6f} seconds") diff --git a/examples/mode2.py b/examples/mode2.py new file mode 100644 index 0000000..c2f98a9 --- /dev/null +++ b/examples/mode2.py @@ -0,0 +1,27 @@ +"""Play 5 patterns for 5 seconds each using play_pattern. + +Pattern 1 runs at 300 fps; the rest at random rates between 10 and 300 fps. +The 4 additional patterns are chosen randomly from IDs 2–700. +""" + +import os +import random + +from arena_interface import ArenaInterface + +PATTERN_IDS = [1] + random.sample(range(2, 10), 4) +RUNTIME_DURATION = 50 # 50 × 100 ms = 5 s + +ip = os.environ.get("ARENA_ETH_IP", "10.103.40.45") +ai = ArenaInterface(debug=True) +ai.set_ethernet_mode(ip_address=ip) + +for pat_id in PATTERN_IDS: + fps = 300 if pat_id == PATTERN_IDS[0] else random.randint(10, 300) + print(f"\n--- Playing pattern {pat_id} at {fps} fps for 5 s ---") + ai.play_pattern( + pattern_id=pat_id, + frame_rate=fps, + runtime_duration=RUNTIME_DURATION, + ) + print(f" Pattern {pat_id} done.") diff --git a/examples/mode3.py b/examples/mode3.py new file mode 100644 index 0000000..6ada4fd --- /dev/null +++ b/examples/mode3.py @@ -0,0 +1,44 @@ +"""Show 5 patterns for 5 seconds each using show_pattern_frame. + +For each pattern, send as many show_pattern_frame commands as possible +at a target rate of 300 Hz with random frame indices between 1 and 15. +""" + +import os +import random +import time + +from arena_interface import ArenaInterface + +PATTERN_IDS = [1] + random.sample(range(2, 10), 4) +DURATION_S = 5.0 +TARGET_RATE_HZ = 300 +FRAME_INDEX_MIN = 1 +FRAME_INDEX_MAX = 15 + +ip = os.environ.get("ARENA_ETH_IP", "10.103.40.45") +ai = ArenaInterface(debug=True) +ai.set_ethernet_mode(ip_address=ip) + +for pat_id in PATTERN_IDS: + print(f"\n--- Showing pattern {pat_id} for {DURATION_S} s at {TARGET_RATE_HZ} Hz ---") + interval = 1.0 / TARGET_RATE_HZ + count = 0 + t_start = time.perf_counter() + deadline = t_start + DURATION_S + + while True: + t_now = time.perf_counter() + if t_now >= deadline: + break + frame_idx = random.randint(FRAME_INDEX_MIN, FRAME_INDEX_MAX) + ai.show_pattern_frame(pattern_id=pat_id, frame_index=frame_idx) + count += 1 + # spin-wait until next slot + next_time = t_start + count * interval + while time.perf_counter() < next_time: + pass + + elapsed = time.perf_counter() - t_start + actual_hz = count / elapsed if elapsed > 0 else 0 + print(f" Pattern {pat_id}: {count} frames in {elapsed:.2f} s ({actual_hz:.1f} Hz)") diff --git a/examples/play_pat.py b/examples/play_pat.py new file mode 100644 index 0000000..14fde6b --- /dev/null +++ b/examples/play_pat.py @@ -0,0 +1,14 @@ +import time + +from arena_interface import ArenaInterface + +ai = ArenaInterface(debug=True) +ai.set_ethernet_mode(ip_address="10.103.40.45") + +# Measure time for play_pattern call +start_time = time.time() +ai.play_pattern(pattern_id=532, frame_rate=20, runtime_duration=10) +end_time = time.time() + +duration = end_time - start_time +print(f"play_pattern() duration: {duration:.6f} seconds") diff --git a/examples/stream_all_patterns.py b/examples/stream_all_patterns.py new file mode 100644 index 0000000..520c3d4 --- /dev/null +++ b/examples/stream_all_patterns.py @@ -0,0 +1,29 @@ +"""Stream each pattern file in patterns/ for 5 seconds.""" + +import os +from pathlib import Path + +from arena_interface import ArenaInterface + +PATTERNS_DIR = Path(__file__).resolve().parent.parent / "patterns" +FRAME_RATE = 0 +RUNTIME_DURATION = 50 # 50 × 100 ms = 5 s + +ip = os.environ.get("ARENA_ETH_IP", "10.103.40.45") +ai = ArenaInterface(debug=True) +ai.set_ethernet_mode(ip_address=ip) + +for pat_file in sorted(PATTERNS_DIR.glob("*.pat")): + print(f"\n--- Streaming {pat_file.name} for 5 s at {FRAME_RATE} Hz ---") + result = ai.stream_frames( + pattern_path=str(pat_file), + frame_rate=FRAME_RATE, + runtime_duration=RUNTIME_DURATION, + analog_out_waveform="constant", + analog_update_rate=1.0, + analog_frequency=0.0, + ) + print(f" frames: {result['frames']}") + print(f" elapsed: {result['elapsed_s']:.2f} s") + print(f" rate: {result['rate_hz']:.1f} Hz") + print(f" tx: {result['tx_mbps']:.2f} Mb/s") diff --git a/pixi.lock b/pixi.lock index 7706ea0..fa874b0 100644 --- a/pixi.lock +++ b/pixi.lock @@ -17,6 +17,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_18.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_18.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-hbdf3cc3_18.conda @@ -53,9 +54,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/perl-5.32.1-7_hd590300_perl5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyserial-3.5-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.3-h32b2ec7_101_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/schedule-1.2.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda @@ -64,7 +67,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl @@ -84,7 +86,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl @@ -108,6 +109,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_31.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/git-2.53.0-pl5321hd1efe10_0.conda @@ -142,9 +144,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.1-hb6871ef_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pcre2-10.47-h13923f0_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/perl-5.32.1-7_h10d778d_perl5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyserial-3.5-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.3-h4f44bb5_101_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/schedule-1.2.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h8d8e812_0.conda @@ -154,7 +158,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl - - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -171,7 +174,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl @@ -194,6 +196,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_31.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/git-2.53.0-pl5321hc9deb11_0.conda @@ -228,9 +231,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.1-hd24854e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/perl-5.32.1-7_h4614cfb_perl5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyserial-3.5-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.3-h4c637c5_101_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/schedule-1.2.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_0.conda @@ -240,7 +245,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl - - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -257,7 +261,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl @@ -271,6 +274,8 @@ environments: win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyha7b4d00_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/git-2.53.0-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.4-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda @@ -279,8 +284,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.52.0-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.1-hf411b9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyserial-3.5-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.3-h4b44e0e_101_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/schedule-1.2.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda @@ -291,8 +298,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -309,7 +314,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl @@ -339,7 +343,7 @@ packages: - pypi: ./ name: arena-interface version: 7.0.1 - sha256: f4f656540d481cab3c307808f486f8f1016c623d46fa8ac651b7d5ad95e152e0 + sha256: 89ef0ffd3e97cdcb998c1c70ade786ee46a5d3b94552e2c9845cf8f62fe9b48a requires_dist: - click>=8.1 - pyserial>=3.5 @@ -754,18 +758,44 @@ packages: purls: [] size: 21135 timestamp: 1769482854554 -- pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - name: click - version: 8.3.1 - sha256: 981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 - requires_dist: - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - name: colorama - version: 0.4.6 - sha256: 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*' +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + sha256: 38cfe1ee75b21a8361c8824f5544c3866f303af1762693a178266d7f198e8715 + md5: ea8a6c3256897cc31263de9f455e25d9 + depends: + - python >=3.10 + - __unix + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/click?source=hash-mapping + size: 97676 + timestamp: 1764518652276 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyha7b4d00_1.conda + sha256: c3bc9a49930fa1c3383a1485948b914823290efac859a2587ca57a270a652e08 + md5: 6cd3ccc98bacfcc92b2bd7f236f01a7e + depends: + - python >=3.10 + - colorama + - __win + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/click?source=hash-mapping + size: 96620 + timestamp: 1764518654675 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/colorama?source=hash-mapping + size: 27011 + timestamp: 1733218222191 - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda sha256: 28e5f0a6293acba68ebc54694a2fc40b1897202735e8e8cbaaa0e975ba7b235b md5: e6b9e71e5cb08f9ed0185d31d33a074b @@ -2402,12 +2432,18 @@ packages: version: 1.2.0 sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - name: pyserial - version: '3.5' - sha256: c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 - requires_dist: - - hidapi ; extra == 'cp2110' +- conda: https://conda.anaconda.org/conda-forge/noarch/pyserial-3.5-pyhcf101f3_2.conda + sha256: 8c618e8ca376d73133c9971abe45463a48c9cfb529788d609fd19568764941ba + md5: b27a6cd32160c34682ce6053b04c12c6 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/pyserial?source=hash-mapping + size: 73910 + timestamp: 1767289527295 - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl name: pytest version: 9.0.2 @@ -2646,6 +2682,17 @@ packages: version: 0.15.6 sha256: 98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e requires_python: '>=3.7' +- conda: https://conda.anaconda.org/conda-forge/noarch/schedule-1.2.2-pyhd8ed1ab_1.conda + sha256: c19be64e5e2b79a9910a41cce8f42a4f47e161c12f9707b04a33b9341df6c7c6 + md5: cedcd7606497aff90b91134046d44370 + depends: + - python >=3.9 + license: MIT + license_family: MIT + purls: + - pkg:pypi/schedule?source=hash-mapping + size: 17298 + timestamp: 1735043793005 - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda sha256: 7e7e2556978bc9bd9628c6e39138c684082320014d708fbca0c9050df98c0968 md5: 68a978f77c0ba6ca10ce55e188a21857 diff --git a/pyproject.toml b/pyproject.toml index d0366ca..c5aeab7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,9 @@ platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] [tool.pixi.dependencies] git = "*" +pyserial = ">=3.5,<4" +click = ">=8.3.1,<9" +schedule = ">=1.2.2,<2" [tool.pixi.pypi-dependencies] "arena-interface" = { path = ".", editable = true } @@ -110,6 +113,8 @@ qtools-install = "python tools/quantum_leaps_tools.py qtools-install" qspy = "python tools/quantum_leaps_tools.py qspy" bench = "arena-interface bench" bench-full = "arena-interface bench --stream-path patterns/pat0004.pat" +bench-max-rate = "arena-interface bench --stream-path patterns/pat0004.pat --stream-max-rate" +bench-max-rate-smoke = "arena-interface bench --cmd-iters 250 --spf-seconds 2 --stream-path patterns/pat0004.pat --stream-seconds 2 --stream-max-rate --stream-max-rate-seconds 2" bench-smoke = "arena-interface bench --cmd-iters 250 --spf-seconds 2 --stream-path patterns/pat0004.pat --stream-seconds 2" bench-persistent = "arena-interface bench --cmd-connect-mode persistent" bench-new-connection = "arena-interface bench --cmd-connect-mode new_connection" diff --git a/src/arena_interface/__init__.py b/src/arena_interface/__init__.py index d202581..95d9f4c 100644 --- a/src/arena_interface/__init__.py +++ b/src/arena_interface/__init__.py @@ -9,10 +9,11 @@ __url__, __version__, ) -from .arena_interface import ArenaInterface +from .arena_interface import ArenaInterface, CommandTimeouts __all__ = [ "ArenaInterface", + "CommandTimeouts", "__author__", "__copyright__", "__description__", diff --git a/src/arena_interface/arena_interface.py b/src/arena_interface/arena_interface.py index f613e95..b7fbdd2 100644 --- a/src/arena_interface/arena_interface.py +++ b/src/arena_interface/arena_interface.py @@ -1,4 +1,5 @@ """Python interface and CLI for the Reiser Lab ArenaController.""" + from __future__ import annotations import atexit @@ -16,8 +17,9 @@ import sys import time from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path -from typing import Callable +from typing import Callable, cast try: import serial @@ -50,6 +52,60 @@ StatusCallback = Callable[[str], None] +@dataclass +class CommandTimeouts: + """Per-command-category response timeouts. + + Inspired by PanelsController.m's ``expectResponse(..., timeout)`` pattern + where each MATLAB command passes its own timeout value. The defaults + below mirror the values chosen in the MATLAB implementation. + + All values are in **seconds**. A value of ``None`` means "use the + instance's transport-level default" (which itself may be ``None`` for + blocking-forever). + + Attributes + ---------- + fast_cmd_s: + Quick request/response commands: ``all_on``, ``display_reset``, + ``set_refresh_rate``, ``get_*``, ``update_pattern_frame``, + ``show_pattern_frame``, ``reset_perf_stats``, etc. + PanelsController.m uses 0.1 s for these. + slow_cmd_s: + Commands whose firmware-side processing takes noticeably longer: + ``all_off``, ``stop_display``. + PanelsController.m uses 0.3 s. + mode_switch_s: + Heavy mode changes such as ``switch_grayscale``. + PanelsController.m uses 2.0 s ("This takes very long"). + stream_frame_s: + Timeout for a single ``STREAM_FRAME`` (0x32) round-trip inside the + ``stream_frames`` loop. There is no direct MATLAB analog (the + ``streamFrame`` method is commented out) but 0.5 s gives comfortable + headroom at ≥200 Hz frame rates. + play_cmd_s: + Timeout for the *initial* ``play_pattern`` / ``play_pattern_analog_closed_loop`` + command exchange (before the blocking wait for completion). + PanelsController.m uses 0.1–0.2 s for comparable initial acks. + """ + + fast_cmd_s: float | None = 0.1 + slow_cmd_s: float | None = 0.3 + mode_switch_s: float | None = 2.0 + stream_frame_s: float | None = 0.5 + play_cmd_s: float | None = 0.2 + + +class _Sentinel: + """Sentinel type used to distinguish 'caller did not pass timeout_s' + from 'caller explicitly passed None' (which means block indefinitely).""" + + __slots__ = () + + +_SENTINEL: _Sentinel = _Sentinel() + + class ArenaInterface: """Python interface to the Reiser lab ArenaController.""" @@ -62,11 +118,24 @@ def __init__( keepalive: bool = True, socket_timeout_s: float | None = SOCKET_TIMEOUT, serial_timeout_s: float | None = SERIAL_TIMEOUT, + command_timeouts: CommandTimeouts | None = None, ): - """Initialize an ArenaInterface instance.""" + """Initialize an ArenaInterface instance. + + Parameters + ---------- + command_timeouts: + Per-command-category response timeouts. When ``None`` (the + default) a :class:`CommandTimeouts` with sensible defaults + (mirroring PanelsController.m) is created automatically. Pass + an explicit instance to override individual categories, or pass + ``CommandTimeouts(fast_cmd_s=None, slow_cmd_s=None, ...)`` to + fall back to the transport-level ``socket_timeout_s`` for every + command. + """ self._debug = bool(debug) self._serial = None - self._ethernet_ip_address = '' + self._ethernet_ip_address = "" self._ethernet_socket: socket.socket | None = None self._socket_reconnects: int = 0 self._socket_last_error: str | None = None @@ -78,6 +147,9 @@ def __init__( self._keepalive = bool(keepalive) self._socket_timeout_s = self._coerce_timeout(socket_timeout_s) self._serial_timeout_s = self._coerce_timeout(serial_timeout_s) + self.command_timeouts = ( + command_timeouts if command_timeouts is not None else CommandTimeouts() + ) atexit.register(self._exit) def __enter__(self): @@ -248,7 +320,9 @@ def record_attempt(attempt: int, ok: bool, error: str | None) -> None: cleanup["all_off_error"] = message cleanup["diagnostics"] = self._collect_linux_net_diagnostics(self._ethernet_ip_address) self._socket_last_error = message - self._bench_emit_status(status_callback, f"[bench] {context}: ALL_OFF failed: {message}") + self._bench_emit_status( + status_callback, f"[bench] {context}: ALL_OFF failed: {message}" + ) should_retry = bool(self._ethernet_ip_address) if should_retry: @@ -362,7 +436,6 @@ def _connect_ethernet_socket(self, repeat_count: int = 10, reuse: bool = True) - raise last_exc if last_exc is not None else ConnectionRefusedError() - def _recv_exact(self, ethernet_socket: socket.socket, n: int) -> bytes: """Receive exactly n bytes from a TCP socket or raise on EOF.""" data = b"" @@ -387,7 +460,15 @@ def _read(self, transport, n: int) -> bytes: if len(data) != n: raise TimeoutError(f"serial read short: expected {n}, got {len(data)}") return data - def _send_and_receive(self, cmd, ethernet_socket=None, *, return_timings: bool = False): + + def _send_and_receive( + self, + cmd, + ethernet_socket=None, + *, + return_timings: bool = False, + timeout_s: float | None | _Sentinel = _SENTINEL, + ): """Send a command and wait for a binary response. If no socket is provided and we're in Ethernet mode, this reuses a @@ -399,43 +480,71 @@ def _send_and_receive(self, cmd, ethernet_socket=None, *, return_timings: bool = If True, return a tuple: (payload_bytes, send_ms, recv_ms), where send_ms is time spent in send/write calls and recv_ms is time spent waiting for and reading the response bytes. + timeout_s: + Per-call response timeout override. When set, the socket (or + serial) timeout is temporarily changed for this exchange and + then restored. ``None`` means "block indefinitely". The + special internal sentinel (the default) means "use the + instance's transport-level default and do not touch the + timeout at all". """ + use_per_call_timeout = not isinstance(timeout_s, _Sentinel) + _effective_timeout_s = cast("float | None", timeout_s) if use_per_call_timeout else None + if self._serial: - t0 = time.perf_counter_ns() - if isinstance(cmd, str): - self._serial.write(cmd.encode()) - else: - self._serial.write(cmd) - t1 = time.perf_counter_ns() - resp_len = self._serial.read(1) - if not resp_len: - raise TimeoutError("serial response length timed out") - response = resp_len + self._serial.read(int(resp_len[0])) - t2 = time.perf_counter_ns() - payload = response[3:] - if return_timings: - return payload, (t1 - t0) / 1e6, (t2 - t1) / 1e6 - return payload + prev_serial_timeout = self._serial.timeout if use_per_call_timeout else None + if use_per_call_timeout: + self._serial.timeout = self._coerce_timeout(_effective_timeout_s) + try: + t0 = time.perf_counter_ns() + if isinstance(cmd, str): + self._serial.write(cmd.encode()) + else: + self._serial.write(cmd) + t1 = time.perf_counter_ns() + resp_len = self._serial.read(1) + if not resp_len: + raise TimeoutError("serial response length timed out") + response = resp_len + self._serial.read(int(resp_len[0])) + t2 = time.perf_counter_ns() + payload = response[3:] + if return_timings: + return payload, (t1 - t0) / 1e6, (t2 - t1) / 1e6 + return payload + finally: + if use_per_call_timeout: + self._serial.timeout = prev_serial_timeout # Ethernet - sock = ethernet_socket if (ethernet_socket is not None) else self._connect_ethernet_socket(reuse=True) + sock = ( + ethernet_socket + if (ethernet_socket is not None) + else self._connect_ethernet_socket(reuse=True) + ) def _do_io(s: socket.socket): - t0 = time.perf_counter_ns() - if isinstance(cmd, str): - s.sendall(cmd.encode()) - else: - s.sendall(cmd) - t1 = time.perf_counter_ns() + prev_sock_timeout = s.gettimeout() if use_per_call_timeout else None + if use_per_call_timeout: + s.settimeout(self._coerce_timeout(_effective_timeout_s)) + try: + t0 = time.perf_counter_ns() + if isinstance(cmd, str): + s.sendall(cmd.encode()) + else: + s.sendall(cmd) + t1 = time.perf_counter_ns() - resp_len = self._recv_exact(s, 1) - payload = self._recv_exact(s, int(resp_len[0])) - t2 = time.perf_counter_ns() + resp_len = self._recv_exact(s, 1) + payload = self._recv_exact(s, int(resp_len[0])) + t2 = time.perf_counter_ns() - out = (resp_len + payload)[3:] - if return_timings: - return out, (t1 - t0) / 1e6, (t2 - t1) / 1e6 - return out + out = (resp_len + payload)[3:] + if return_timings: + return out, (t1 - t0) / 1e6, (t2 - t1) / 1e6 + return out + finally: + if use_per_call_timeout: + s.settimeout(prev_sock_timeout) # If we're using the persistent socket, allow one reconnect attempt. attempts = 1 if (ethernet_socket is not None) else 2 @@ -452,13 +561,15 @@ def _do_io(s: socket.socket): sock = self._connect_ethernet_socket(reuse=True) raise ConnectionError("failed to send/receive over Ethernet after reconnect") + def _send_and_receive_stream( - self, - stream_header: bytes, - frame_chunked: list[bytes], - ethernet_socket: socket.socket | None = None, - *, - return_timings: bool = False, + self, + stream_header: bytes, + frame_chunked: list[bytes], + ethernet_socket: socket.socket | None = None, + *, + return_timings: bool = False, + timeout_s: float | None | _Sentinel = _SENTINEL, ): """Send a stream frame (header + payload) and wait for response. @@ -471,24 +582,39 @@ def _send_and_receive_stream( instance's persistent Ethernet socket. return_timings: If True, return (payload_bytes, send_ms, recv_ms). + timeout_s: + Per-call response timeout override (see ``_send_and_receive``). """ - sock = ethernet_socket if (ethernet_socket is not None) else self._connect_ethernet_socket(reuse=True) + use_per_call_timeout = not isinstance(timeout_s, _Sentinel) + _effective_timeout_s = cast("float | None", timeout_s) if use_per_call_timeout else None + sock = ( + ethernet_socket + if (ethernet_socket is not None) + else self._connect_ethernet_socket(reuse=True) + ) def _do_io(s: socket.socket): - t0 = time.perf_counter_ns() - s.sendall(stream_header) - for chunk in frame_chunked: - s.sendall(chunk) - t1 = time.perf_counter_ns() + prev_sock_timeout = s.gettimeout() if use_per_call_timeout else None + if use_per_call_timeout: + s.settimeout(self._coerce_timeout(_effective_timeout_s)) + try: + t0 = time.perf_counter_ns() + s.sendall(stream_header) + for chunk in frame_chunked: + s.sendall(chunk) + t1 = time.perf_counter_ns() - resp_len = self._recv_exact(s, 1) - payload = self._recv_exact(s, int(resp_len[0])) - t2 = time.perf_counter_ns() + resp_len = self._recv_exact(s, 1) + payload = self._recv_exact(s, int(resp_len[0])) + t2 = time.perf_counter_ns() - out = (resp_len + payload)[3:] - if return_timings: - return out, (t1 - t0) / 1e6, (t2 - t1) / 1e6 - return out + out = (resp_len + payload)[3:] + if return_timings: + return out, (t1 - t0) / 1e6, (t2 - t1) / 1e6 + return out + finally: + if use_per_call_timeout: + s.settimeout(prev_sock_timeout) # If we're using the persistent socket, allow one reconnect attempt. attempts = 1 if (ethernet_socket is not None) else 2 @@ -523,7 +649,7 @@ def set_serial_mode(self, port, baudrate=SERIAL_BAUDRATE): ) self._close_ethernet_socket() - self._ethernet_ip_address = '' + self._ethernet_ip_address = "" if self._serial: self._serial.close() @@ -534,7 +660,6 @@ def set_serial_mode(self, port, baudrate=SERIAL_BAUDRATE): self._serial.open() return self._serial.is_open - def _close_ethernet_socket(self): """Close and forget the persistent Ethernet socket (if any).""" if self._ethernet_socket is not None: @@ -556,81 +681,91 @@ def close(self): def all_off(self): """Turn all panels off.""" - self._send_and_receive(b'\x01\x00') + self._send_and_receive(b"\x01\x00", timeout_s=self.command_timeouts.slow_cmd_s) def display_reset(self): """Reset arena.""" - self._send_and_receive(b'\x01\x01') + self._send_and_receive(b"\x01\x01", timeout_s=self.command_timeouts.fast_cmd_s) def switch_grayscale(self, grayscale_index): """Switches grayscale value. grayscale_index: 0=binary, 1=grayscale""" - cmd_bytes = struct.pack(' bytes: """Fetch a raw performance stats snapshot (binary payload).""" - return self._send_and_receive(b'\x01\x71', ethernet_socket) + return self._send_and_receive( + b"\x01\x71", ethernet_socket, timeout_s=self.command_timeouts.fast_cmd_s + ) def reset_perf_stats(self, ethernet_socket=None): """Reset performance counters on the device.""" - self._send_and_receive(b'\x01\x72', ethernet_socket) + self._send_and_receive( + b"\x01\x72", ethernet_socket, timeout_s=self.command_timeouts.fast_cmd_s + ) def all_on(self): """Turn all panels on.""" - self._send_and_receive(b'\x01\xff') + self._send_and_receive(b"\x01\xff", timeout_s=self.command_timeouts.fast_cmd_s) def stream_frame(self, path, frame_index, analog_output_value=0): """Stream frame in pattern file.""" - self._debug_print('pattern path: ', path) - with open(path, mode='rb') as f: + self._debug_print("pattern path: ", path) + with open(path, mode="rb") as f: content = f.read() - pattern_header = struct.unpack(' (frame_count - 1): frame_index = frame_count - 1 - self._debug_print('frame_index: ', frame_index) - frame_len = len(frames)//frame_count + self._debug_print("frame_index: ", frame_index) + frame_len = len(frames) // frame_count frame_start = frame_index * frame_len # self._debug_print('frame_start: ', frame_start) frame_end = frame_start + frame_len @@ -753,12 +911,12 @@ def stream_frame(self, path, frame_index, analog_output_value=0): frame = frames[frame_start:frame_end] data_len = len(frame) # self._debug_print('data_len: ', data_len) - frame_header = struct.pack('][frame bytes...] # @@ -821,10 +984,14 @@ def stream_frames( # Try ".pattern" if file_size >= 4: frame_size = struct.unpack(" 0 and ((file_size - 4) % frame_size == 0): + if ( + 0 < frame_size <= 65535 + and (file_size - 4) > 0 + and ((file_size - 4) % frame_size == 0) + ): num_frames = int((file_size - 4) / frame_size) frames = [ - file_bytes[4 + (i * frame_size): 4 + ((i + 1) * frame_size)] + file_bytes[4 + (i * frame_size) : 4 + ((i + 1) * frame_size)] for i in range(num_frames) ] @@ -850,30 +1017,35 @@ def stream_frames( ) num_frames = int(frame_count) - frames = [blob[i * frame_size:(i + 1) * frame_size] for i in range(num_frames)] + frames = [blob[i * frame_size : (i + 1) * frame_size] for i in range(num_frames)] runtime_duration_s = float(runtime_duration) / float(RUNTIME_DURATION_PER_SECOND) frames_target = int(runtime_duration_s * float(frame_rate)) if frame_rate else 0 frame_period_ns = int((1.0 / float(frame_rate)) * 1e9) if frame_rate else 0 - analog_update_period_ns = int((1.0 / float(analog_update_rate)) * 1e9) if analog_update_rate else 0 + analog_update_period_ns = ( + int((1.0 / float(analog_update_rate)) * 1e9) if analog_update_rate else 0 + ) # Map waveform output [-1..1] into a conservative 12-bit-ish range. analog_amplitude = (ANALOG_OUTPUT_VALUE_MAX - ANALOG_OUTPUT_VALUE_MIN) / 2.0 analog_offset = (ANALOG_OUTPUT_VALUE_MAX + ANALOG_OUTPUT_VALUE_MIN) / 2.0 def analog_waveform_for(name: str): - if name == 'sin': + if name == "sin": return math.sin - if name == 'square': + if name == "square": return lambda x: 1.0 if math.sin(x) >= 0 else -1.0 - if name == 'sawtooth': + if name == "sawtooth": return lambda x: 2.0 * (x / (2.0 * math.pi) - math.floor(0.5 + x / (2.0 * math.pi))) - if name == 'triangle': - return lambda x: 2.0 * abs(2.0 * (x / (2.0 * math.pi) - math.floor(0.5 + x / (2.0 * math.pi)))) - 1.0 - if name == 'constant': + if name == "triangle": + return lambda x: ( + 2.0 * abs(2.0 * (x / (2.0 * math.pi) - math.floor(0.5 + x / (2.0 * math.pi)))) + - 1.0 + ) + if name == "constant": return lambda x: 0.0 - raise ValueError(f'Invalid analog output waveform: {name}') + raise ValueError(f"Invalid analog output waveform: {name}") # Ensure persistent socket is established once for the run. self._connect_ethernet_socket(reuse=True) @@ -928,7 +1100,10 @@ def analog_waveform_for(name: str): # Analog output update (optional) now_ns = time.perf_counter_ns() - if analog_update_period_ns and (now_ns - last_analog_update_ns) >= analog_update_period_ns: + if ( + analog_update_period_ns + and (now_ns - last_analog_update_ns) >= analog_update_period_ns + ): t_s = (now_ns - start_time_ns) / 1e9 analog_phase = (t_s * float(analog_frequency)) * (2.0 * math.pi) analog_output_value_f = analog_amplitude * float(wf(analog_phase)) + analog_offset @@ -944,33 +1119,44 @@ def analog_waveform_for(name: str): # Stream frame header: cmd(0x32), data_len(uint16), analog(uint16), reserved(uint16) data_len = len(frame) - stream_header = struct.pack(' 0 else 0.0 if frames_target: - self._bench_emit_status(status_callback, f'[bench] stream_frames: {frames_streamed}/{frames_target} frames ({rate_hz:.1f} Hz)') + self._bench_emit_status( + status_callback, + f"[bench] stream_frames: {frames_streamed}/{frames_target} frames ({rate_hz:.1f} Hz)", + ) else: - self._bench_emit_status(status_callback, f'[bench] stream_frames: {frames_streamed} frames ({rate_hz:.1f} Hz)') + self._bench_emit_status( + status_callback, + f"[bench] stream_frames: {frames_streamed} frames ({rate_hz:.1f} Hz)", + ) next_progress_ns += int(progress_interval_s * 1e9) if frame_period_ns: next_frame_deadline_ns += frame_period_ns i += 1 if stop_after: - self._send_and_receive(bytes([1, 0])) + self._send_and_receive(bytes([1, 0]), timeout_s=self.command_timeouts.slow_cmd_s) elapsed_s = (time.perf_counter_ns() - start_time_ns) / 1e9 rate_hz = frames_streamed / elapsed_s if elapsed_s > 0 else 0.0 mbps = (bytes_sent * 8) / (elapsed_s * 1e6) if elapsed_s > 0 else 0.0 - self._bench_emit_status(status_callback, f'[bench] stream_frames: frames={frames_streamed} elapsed_s={elapsed_s:.3f} rate={rate_hz:.1f} Hz tx={mbps:.2f} Mb/s') + self._bench_emit_status( + status_callback, + f"[bench] stream_frames: frames={frames_streamed} elapsed_s={elapsed_s:.3f} rate={rate_hz:.1f} Hz tx={mbps:.2f} Mb/s", + ) result = { "frames": frames_streamed, @@ -1017,11 +1212,11 @@ def analog_waveform_for(name: str): def all_off_str(self): """Turn all panels off with string.""" - self._send_and_receive('ALL_OFF') + self._send_and_receive("ALL_OFF", timeout_s=self.command_timeouts.slow_cmd_s) def all_on_str(self): """Turn all panels on with string.""" - self._send_and_receive('ALL_ON') + self._send_and_receive("ALL_ON", timeout_s=self.command_timeouts.fast_cmd_s) # --------------------------------------------------------------------- # Benchmark helpers (host-side) @@ -1135,7 +1330,9 @@ def bench_metadata(self, label: str | None = None) -> dict: "package_version": pkg_version, "transport": "serial" if (self._serial is not None) else "ethernet", "ethernet_ip": self._ethernet_ip_address if self._ethernet_ip_address else None, - "serial_port": getattr(self._serial, "port", None) if self._serial is not None else None, + "serial_port": getattr(self._serial, "port", None) + if self._serial is not None + else None, "tcp_nodelay": self._tcp_nodelay, "tcp_quickack_requested": self._tcp_quickack_requested, "tcp_quickack_supported": self._tcp_quickack_supported, @@ -1160,7 +1357,9 @@ def bench_metadata(self, label: str | None = None) -> dict: peer_ip = meta.get("ethernet_ip") if peer_ip: try: - route_out = subprocess.check_output(["ip", "route", "get", str(peer_ip)], text=True).strip() + route_out = subprocess.check_output( + ["ip", "route", "get", str(peer_ip)], text=True + ).strip() meta["net_route_get"] = route_out m = re.search(r"\bdev\s+(\S+)", route_out) iface = m.group(1) if m else None @@ -1206,7 +1405,6 @@ def _read_sysfs(name: str) -> str | None: # Non-Linux hosts (or minimal containers) may not have `ip` or sysfs. pass - return meta def bench_connect_time(self, iters: int = 200) -> dict: @@ -1241,13 +1439,13 @@ def bench_connect_time(self, iters: int = 200) -> dict: return summary def bench_command_rtt( - self, - iters: int = 2000, - wrap_mode: bool = True, - connect_mode: str = "persistent", - warmup: int = 20, - progress_interval_s: float = 1.0, - status_callback: StatusCallback | None = None, + self, + iters: int = 2000, + wrap_mode: bool = True, + connect_mode: str = "persistent", + warmup: int = 20, + progress_interval_s: float = 1.0, + status_callback: StatusCallback | None = None, ) -> dict: """Measure host-side RTT for a small request/response command. @@ -1280,7 +1478,9 @@ def bench_command_rtt( reconnects_before = self.get_socket_reconnects(reset=False) cleanup: dict[str, object] | None = None - progress_step_ns = max(1, int(float(progress_interval_s) * 1e9)) if progress_interval_s > 0 else 0 + progress_step_ns = ( + max(1, int(float(progress_interval_s) * 1e9)) if progress_interval_s > 0 else 0 + ) try: if wrap_mode: @@ -1304,9 +1504,7 @@ def bench_command_rtt( bytes_rx = 0 errors = 0 measure_start_ns = time.perf_counter_ns() - next_progress_ns = ( - measure_start_ns + progress_step_ns if progress_step_ns > 0 else None - ) + next_progress_ns = measure_start_ns + progress_step_ns if progress_step_ns > 0 else None for iteration in range(int(iters)): if connect_mode == "persistent": @@ -1315,7 +1513,7 @@ def bench_command_rtt( t1 = time.perf_counter_ns() rtts_ms.append((t1 - t0) / 1e6) bytes_tx += 2 # b'q' - bytes_rx += (len(payload) + 3) # status + echo + payload (length excluded) + bytes_rx += len(payload) + 3 # status + echo + payload (length excluded) else: s = self._open_ethernet_socket() try: @@ -1324,7 +1522,7 @@ def bench_command_rtt( t1 = time.perf_counter_ns() rtts_ms.append((t1 - t0) / 1e6) bytes_tx += 2 - bytes_rx += (len(payload) + 3) + bytes_rx += len(payload) + 3 except Exception: errors += 1 finally: @@ -1370,16 +1568,16 @@ def bench_command_rtt( return summary def bench_spf_updates( - self, - rate_hz: float = 200.0, - seconds: float = 5.0, - pattern_id: int = 10, - frame_min: int = 0, - frame_max: int = 1000, - pacing: str = "target", - warmup: int = 0, - progress_interval_s: float = 1.0, - status_callback: StatusCallback | None = None, + self, + rate_hz: float = 200.0, + seconds: float = 5.0, + pattern_id: int = 10, + frame_min: int = 0, + frame_max: int = 1000, + pacing: str = "target", + warmup: int = 0, + progress_interval_s: float = 1.0, + status_callback: StatusCallback | None = None, ) -> dict: """Benchmark SHOW_PATTERN_FRAME update performance (SPF). @@ -1405,7 +1603,9 @@ def bench_spf_updates( reconnects_before = self.get_socket_reconnects(reset=False) cleanup: dict[str, object] | None = None - progress_step_ns = max(1, int(float(progress_interval_s) * 1e9)) if progress_interval_s > 0 else 0 + progress_step_ns = ( + max(1, int(float(progress_interval_s) * 1e9)) if progress_interval_s > 0 else 0 + ) try: self.reset_perf_stats() @@ -1522,17 +1722,17 @@ def bench_spf_updates( return summary def bench_stream_frames( - self, - pattern_path: str, - frame_rate: float = 200.0, - seconds: float = 5.0, - stream_cmd_coalesced: bool = True, - progress_interval_s: float = 1.0, - analog_out_waveform: str = "constant", - analog_update_rate: float = 1.0, - analog_frequency: float = 0.0, - collect_timings: bool = True, - status_callback: StatusCallback | None = None, + self, + pattern_path: str, + frame_rate: float = 200.0, + seconds: float = 5.0, + stream_cmd_coalesced: bool = True, + progress_interval_s: float = 1.0, + analog_out_waveform: str = "constant", + analog_update_rate: float = 1.0, + analog_frequency: float = 0.0, + collect_timings: bool = True, + status_callback: StatusCallback | None = None, ) -> dict: """Benchmark STREAM_FRAME throughput using `stream_frames()`. @@ -1586,27 +1786,120 @@ def bench_stream_frames( return stats + def bench_stream_frames_max_rate( + self, + pattern_path: str, + seconds: float = 5.0, + stream_cmd_coalesced: bool = True, + progress_interval_s: float = 1.0, + collect_timings: bool = True, + status_callback: StatusCallback | None = None, + ) -> dict: + """Benchmark STREAM_FRAME throughput with no pacing (as fast as possible). + + This is identical to :meth:`bench_stream_frames` but forces + ``frame_rate=0`` so frames are sent back-to-back with no sleep/spin + pacing. The achieved rate is therefore bounded only by the host TCP + stack, the network, and the firmware's ability to accept and process + frames. + + Use this to find the **maximum sustainable throughput** of a given + pattern size across firmware builds, Ethernet stacks, switches, and + host machines. + + Parameters + ---------- + pattern_path: + Path to a ``.pattern`` or ``.pat`` file (same formats as + :meth:`stream_frames`). + seconds: + Wall-clock duration of the streaming burst. + stream_cmd_coalesced: + If True, send the stream header and frame payload in a single + ``sendall``; otherwise chunk the payload. + progress_interval_s: + How often to emit progress via *status_callback*. + collect_timings: + If True, record per-frame send/recv timing breakdowns. + status_callback: + Optional callable for progress/status messages. + + Returns + ------- + dict + The same structure as :meth:`bench_stream_frames` with an extra + ``"pacing": "max"`` key so results are easy to distinguish from + rate-limited runs. + """ + # Clear any prior socket error so results are per-run. + self._socket_last_error = None + + reconnects_before = self.get_socket_reconnects(reset=False) + cleanup_error: str | None = None + + try: + self.reset_perf_stats() + + runtime_duration = int(round(float(seconds) * float(RUNTIME_DURATION_PER_SECOND))) + stats = self.stream_frames( + str(pattern_path), + 0, # frame_rate=0 → no pacing, send as fast as possible + runtime_duration, + "constant", # analog waveform irrelevant at max rate + 0, # analog_update_rate=0 → disabled + 0.0, # analog_frequency + stream_cmd_coalesced=bool(stream_cmd_coalesced), + progress_interval_s=float(progress_interval_s), + collect_timings=bool(collect_timings), + status_callback=status_callback, + stop_after=False, + ) + stats.update( + { + "pacing": "max", + "pattern_path": str(pattern_path), + "frame_rate": 0, + "seconds": float(seconds), + "stream_cmd_coalesced": bool(stream_cmd_coalesced), + "reconnects": int(self.get_socket_reconnects(reset=False) - reconnects_before), + "last_socket_error": self._socket_last_error, + } + ) + finally: + cleanup_error = self._safe_all_off( + status_callback=status_callback, + context="stream_frames_max_rate cleanup", + ) + + if cleanup_error is not None: + raise RuntimeError(f"stream_frames_max_rate cleanup failed: {cleanup_error}") + + return stats + def bench_suite( - self, - label: str | None = None, - *, - include_connect: bool = False, - connect_iters: int = 200, - cmd_iters: int = 2000, - cmd_connect_mode: str = "persistent", - spf_rate: float = 200.0, - spf_seconds: float = 5.0, - spf_pattern_id: int = 10, - spf_frame_min: int = 0, - spf_frame_max: int = 1000, - spf_pacing: str = "target", - stream_path: str | None = None, - stream_rate: float = 200.0, - stream_seconds: float = 5.0, - stream_coalesced: bool = True, - progress_interval_s: float = 1.0, - bench_io_timeout_s: float | None = BENCH_IO_TIMEOUT_S, - status_callback: StatusCallback | None = None, + self, + label: str | None = None, + *, + include_connect: bool = False, + connect_iters: int = 200, + cmd_iters: int = 2000, + cmd_connect_mode: str = "persistent", + spf_rate: float = 200.0, + spf_seconds: float = 5.0, + spf_pattern_id: int = 10, + spf_frame_min: int = 0, + spf_frame_max: int = 1000, + spf_pacing: str = "target", + stream_path: str | None = None, + stream_rate: float = 200.0, + stream_seconds: float = 5.0, + stream_coalesced: bool = True, + stream_max_rate: bool = False, + stream_max_rate_seconds: float = 5.0, + stream_max_rate_coalesced: bool = True, + progress_interval_s: float = 1.0, + bench_io_timeout_s: float | None = BENCH_IO_TIMEOUT_S, + status_callback: StatusCallback | None = None, ) -> dict: """Run a repeatable benchmark suite and return structured results. @@ -1759,6 +2052,21 @@ def run_phase(name: str, fn, /, **kwargs) -> bool: ): return self._bench_finalize_suite_results(results) + if ( + stream_path + and stream_max_rate + and not run_phase( + "stream_frames_max_rate", + self.bench_stream_frames_max_rate, + pattern_path=str(stream_path), + seconds=float(stream_max_rate_seconds), + stream_cmd_coalesced=bool(stream_max_rate_coalesced), + progress_interval_s=float(progress_interval_s), + status_callback=status_callback, + ) + ): + return self._bench_finalize_suite_results(results) + return self._bench_finalize_suite_results(results) def _bench_finalize_suite_results(self, results: dict) -> dict: diff --git a/src/arena_interface/bench.py b/src/arena_interface/bench.py index f411644..3b543b9 100644 --- a/src/arena_interface/bench.py +++ b/src/arena_interface/bench.py @@ -7,6 +7,7 @@ - ``ai.bench_command_rtt(...)`` - ``ai.bench_spf_updates(...)`` - ``ai.bench_stream_frames(...)`` +- ``ai.bench_stream_frames_max_rate(...)`` - ``ai.bench_suite(...)`` This module keeps thin wrapper functions for backwards compatibility and for @@ -25,11 +26,11 @@ def bench_connect_time(arena_interface: ArenaInterface, iters: int = 200) -> dic def bench_command_rtt( - arena_interface: ArenaInterface, - iters: int = 2000, - wrap_mode: bool = True, - connect_mode: str = "persistent", - warmup: int = 20, + arena_interface: ArenaInterface, + iters: int = 2000, + wrap_mode: bool = True, + connect_mode: str = "persistent", + warmup: int = 20, ) -> dict[str, Any]: return arena_interface.bench_command_rtt( iters=int(iters), @@ -40,14 +41,14 @@ def bench_command_rtt( def bench_spf_updates( - arena_interface: ArenaInterface, - rate_hz: float = 200.0, - seconds: float = 5.0, - pattern_id: int = 10, - frame_min: int = 0, - frame_max: int = 1000, - pacing: str = "target", - warmup: int = 0, + arena_interface: ArenaInterface, + rate_hz: float = 200.0, + seconds: float = 5.0, + pattern_id: int = 10, + frame_min: int = 0, + frame_max: int = 1000, + pacing: str = "target", + warmup: int = 0, ) -> dict[str, Any]: return arena_interface.bench_spf_updates( rate_hz=float(rate_hz), @@ -61,16 +62,16 @@ def bench_spf_updates( def bench_stream_frames( - arena_interface: ArenaInterface, - pattern_path: str, - frame_rate: float = 200.0, - seconds: float = 5.0, - stream_cmd_coalesced: bool = True, - progress_interval_s: float = 1.0, - analog_out_waveform: str = "constant", - analog_update_rate: float = 1.0, - analog_frequency: float = 0.0, - collect_timings: bool = True, + arena_interface: ArenaInterface, + pattern_path: str, + frame_rate: float = 200.0, + seconds: float = 5.0, + stream_cmd_coalesced: bool = True, + progress_interval_s: float = 1.0, + analog_out_waveform: str = "constant", + analog_update_rate: float = 1.0, + analog_frequency: float = 0.0, + collect_timings: bool = True, ) -> dict[str, Any]: return arena_interface.bench_stream_frames( pattern_path=str(pattern_path), @@ -85,25 +86,46 @@ def bench_stream_frames( ) +def bench_stream_frames_max_rate( + arena_interface: ArenaInterface, + pattern_path: str, + seconds: float = 5.0, + stream_cmd_coalesced: bool = True, + progress_interval_s: float = 1.0, + collect_timings: bool = True, +) -> dict[str, Any]: + """Thin wrapper around :meth:`ArenaInterface.bench_stream_frames_max_rate`.""" + return arena_interface.bench_stream_frames_max_rate( + pattern_path=str(pattern_path), + seconds=float(seconds), + stream_cmd_coalesced=bool(stream_cmd_coalesced), + progress_interval_s=float(progress_interval_s), + collect_timings=bool(collect_timings), + ) + + def bench_suite( - arena_interface: ArenaInterface, - label: str | None = None, - *, - include_connect: bool = False, - connect_iters: int = 200, - cmd_iters: int = 2000, - cmd_connect_mode: str = "persistent", - spf_rate: float = 200.0, - spf_seconds: float = 5.0, - spf_pattern_id: int = 10, - spf_frame_min: int = 0, - spf_frame_max: int = 1000, - spf_pacing: str = "target", - stream_path: str | None = None, - stream_rate: float = 200.0, - stream_seconds: float = 5.0, - stream_coalesced: bool = True, - progress_interval_s: float = 1.0, + arena_interface: ArenaInterface, + label: str | None = None, + *, + include_connect: bool = False, + connect_iters: int = 200, + cmd_iters: int = 2000, + cmd_connect_mode: str = "persistent", + spf_rate: float = 200.0, + spf_seconds: float = 5.0, + spf_pattern_id: int = 10, + spf_frame_min: int = 0, + spf_frame_max: int = 1000, + spf_pacing: str = "target", + stream_path: str | None = None, + stream_rate: float = 200.0, + stream_seconds: float = 5.0, + stream_coalesced: bool = True, + stream_max_rate: bool = False, + stream_max_rate_seconds: float = 5.0, + stream_max_rate_coalesced: bool = True, + progress_interval_s: float = 1.0, ) -> dict[str, Any]: return arena_interface.bench_suite( label=label, @@ -121,6 +143,9 @@ def bench_suite( stream_rate=float(stream_rate), stream_seconds=float(stream_seconds), stream_coalesced=bool(stream_coalesced), + stream_max_rate=bool(stream_max_rate), + stream_max_rate_seconds=float(stream_max_rate_seconds), + stream_max_rate_coalesced=bool(stream_max_rate_coalesced), progress_interval_s=float(progress_interval_s), ) diff --git a/src/arena_interface/cli.py b/src/arena_interface/cli.py index dbd2cbc..4b806b2 100755 --- a/src/arena_interface/cli.py +++ b/src/arena_interface/cli.py @@ -6,8 +6,7 @@ import click -from .arena_interface import ArenaInterface, BENCH_IO_TIMEOUT_S, SERIAL_BAUDRATE - +from .arena_interface import BENCH_IO_TIMEOUT_S, SERIAL_BAUDRATE, ArenaInterface pass_arena_interface = click.make_pass_decorator(ArenaInterface) @@ -84,7 +83,37 @@ def _print_suite_summary( if isinstance(st.get("cmd_rtt_ms"), dict): cmd = st.get("cmd_rtt_ms") or {} send = st.get("send_ms") if isinstance(st.get("send_ms"), dict) else {} - wait = st.get("response_wait_ms") if isinstance(st.get("response_wait_ms"), dict) else {} + wait = ( + st.get("response_wait_ms") if isinstance(st.get("response_wait_ms"), dict) else {} + ) + extra = " rtt_p99={p99:.3f} ms (send_p99={sp99:.3f} ms wait_p99={wp99:.3f} ms)".format( + p99=float(cmd.get("p99_ms", float("nan"))), + sp99=float(send.get("p99_ms", float("nan"))), + wp99=float(wait.get("p99_ms", float("nan"))), + ) + + click.echo( + "frames={frames} elapsed_s={elapsed_s:.3f} rate={rate_hz:.1f} Hz tx={tx_mbps:.2f} Mb/s reconnects={reconnects}{extra}".format( + frames=st.get("frames"), + elapsed_s=st.get("elapsed_s"), + rate_hz=st.get("rate_hz"), + tx_mbps=st.get("tx_mbps"), + reconnects=st.get("reconnects"), + extra=extra, + ) + ) + + if stream_requested and ("stream_frames_max_rate" in suite): + click.echo("\n-- stream_frames_max_rate (no pacing) --") + st = suite["stream_frames_max_rate"] + + extra = "" + if isinstance(st.get("cmd_rtt_ms"), dict): + cmd = st.get("cmd_rtt_ms") or {} + send = st.get("send_ms") if isinstance(st.get("send_ms"), dict) else {} + wait = ( + st.get("response_wait_ms") if isinstance(st.get("response_wait_ms"), dict) else {} + ) extra = " rtt_p99={p99:.3f} ms (send_p99={sp99:.3f} ms wait_p99={wp99:.3f} ms)".format( p99=float(cmd.get("p99_ms", float("nan"))), sp99=float(send.get("p99_ms", float("nan"))), @@ -272,8 +301,12 @@ def get_perf_stats(arena_interface: ArenaInterface): show_default=True, help="Include a TCP connect() timing test (Ethernet only).", ) -@click.option("--connect-iters", default=200, show_default=True, help="Iterations for connect() timing test") -@click.option("--cmd-iters", default=2000, show_default=True, help="Iterations for command RTT test") +@click.option( + "--connect-iters", default=200, show_default=True, help="Iterations for connect() timing test" +) +@click.option( + "--cmd-iters", default=2000, show_default=True, help="Iterations for command RTT test" +) @click.option( "--cmd-connect-mode", type=click.Choice(["persistent", "new_connection"], case_sensitive=False), @@ -281,8 +314,12 @@ def get_perf_stats(arena_interface: ArenaInterface): show_default=True, help="Use a persistent socket or open/close a new TCP connection per command.", ) -@click.option("--spf-rate", default=200.0, show_default=True, help="Target Hz for update_pattern_frame loop") -@click.option("--spf-seconds", default=5.0, show_default=True, help="Seconds to run update_pattern_frame loop") +@click.option( + "--spf-rate", default=200.0, show_default=True, help="Target Hz for update_pattern_frame loop" +) +@click.option( + "--spf-seconds", default=5.0, show_default=True, help="Seconds to run update_pattern_frame loop" +) @click.option("--spf-pattern-id", default=10, show_default=True) @click.option("--spf-frame-min", default=0, show_default=True) @click.option("--spf-frame-max", default=1000, show_default=True) @@ -299,10 +336,31 @@ def get_perf_stats(arena_interface: ArenaInterface): default=None, help="Optional .pattern or .pat file to stream", ) -@click.option("--stream-rate", default=200.0, show_default=True, help="Target FPS for stream_frames") -@click.option("--stream-seconds", default=5.0, show_default=True, help="Seconds to run stream_frames") +@click.option( + "--stream-rate", default=200.0, show_default=True, help="Target FPS for stream_frames" +) +@click.option( + "--stream-seconds", default=5.0, show_default=True, help="Seconds to run stream_frames" +) @click.option("--stream-coalesced/--stream-chunked", default=True, show_default=True) -@click.option("--progress-interval", default=1.0, show_default=True, help="Progress print interval (seconds)") +@click.option( + "--stream-max-rate/--no-stream-max-rate", + default=False, + show_default=True, + help="Include a max-throughput (no pacing) streaming test. Requires --stream-path.", +) +@click.option( + "--stream-max-rate-seconds", + default=5.0, + show_default=True, + help="Seconds to run max-rate stream", +) +@click.option( + "--stream-max-rate-coalesced/--stream-max-rate-chunked", default=True, show_default=True +) +@click.option( + "--progress-interval", default=1.0, show_default=True, help="Progress print interval (seconds)" +) @click.option( "--io-timeout", default=BENCH_IO_TIMEOUT_S, @@ -328,6 +386,9 @@ def bench( stream_rate: float, stream_seconds: float, stream_coalesced: bool, + stream_max_rate: bool, + stream_max_rate_seconds: float, + stream_max_rate_coalesced: bool, progress_interval: float, io_timeout: float, ): @@ -354,6 +415,9 @@ def bench( stream_rate=float(stream_rate), stream_seconds=float(stream_seconds), stream_coalesced=bool(stream_coalesced), + stream_max_rate=bool(stream_max_rate), + stream_max_rate_seconds=float(stream_max_rate_seconds), + stream_max_rate_coalesced=bool(stream_max_rate_coalesced), progress_interval_s=float(progress_interval), bench_io_timeout_s=float(io_timeout), status_callback=click.echo,