diff --git a/CHANGELOG.md b/CHANGELOG.md index a886a861..510c6978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to GeneLab are recorded here. ## [Unreleased] +## [0.4.0] — 2026-07-03 + +### Changed + +- **Genesis 1.2:** the simulator floor moves to `genesis-world>=1.2.0,<1.3.0` + (from the 1.0.x pin). GeneLab-side adaptations: `SimulationCfg.render_fps` now + maps to `ViewerOptions.refresh_rate` (Genesis split the old `max_FPS` into + `refresh_rate` for repaints and `realtime_factor` for sim pacing; `render_fps=None` + disables the pacer to keep the documented "uncapped" contract), and the ImGui + panel host is discovered through the public `viewer.plugins` property (the + private `_viewer_plugins` attribute was renamed upstream; kept as a fallback). + Note for pre-trained checkpoints: Genesis 1.2 parses kinematic trees depth-first, + so joint/link enumeration order changes on branched robots — GeneLab resolves + joints by name and stays self-consistent, but policies trained on 1.0.x whose + observation/action layouts baked the old order must be re-mapped or re-trained. + ## [0.3.3] — 2026-06-05 > Git history was rewritten in this release to purge ~24 MB of accidentally-committed vendored diff --git a/docs/getting-started/installation.en.md b/docs/getting-started/installation.en.md index ee731b9a..3fda8e94 100644 --- a/docs/getting-started/installation.en.md +++ b/docs/getting-started/installation.en.md @@ -9,7 +9,7 @@ A focused installation checklist. For the full learning path, start with the |---|---| | Python | 3.12 or newer | | Dependency manager | `uv` | -| Simulator backend | `genesis-world>=1.0.0,<1.1.0` (1.1.x changes APIs GeneLab relies on) | +| Simulator backend | `genesis-world>=1.2.0,<1.3.0` (Genesis changes APIs across minor releases; capped until validated) | | PyTorch | `torch>=2.8.0` through one `torch-*` extra | ## Syncing the environment diff --git a/docs/getting-started/installation.zh.md b/docs/getting-started/installation.zh.md index 24c06f2e..33f7e2ab 100644 --- a/docs/getting-started/installation.zh.md +++ b/docs/getting-started/installation.zh.md @@ -8,7 +8,7 @@ |---|---| | Python | 3.12 或更新 | | 依赖管理 | `uv` | -| 仿真后端 | `genesis-world>=1.0.0,<1.1.0`(1.1.x 改动了 GeneLab 依赖的 API) | +| 仿真后端 | `genesis-world>=1.2.0,<1.3.0`(Genesis 的次版本会改动 API,验证前先设上限) | | PyTorch | 通过一个 `torch-*` extra 安装 `torch>=2.8.0` | ## 同步环境 diff --git a/examples/genelab_examples/src/genelab_examples/envs.py b/examples/genelab_examples/src/genelab_examples/envs.py index 199c3445..b84c99b4 100644 --- a/examples/genelab_examples/src/genelab_examples/envs.py +++ b/examples/genelab_examples/src/genelab_examples/envs.py @@ -90,7 +90,7 @@ def play(self) -> None: camera_pos=(0.45, -0.55, 0.35), camera_lookat=(0.0, 0.0, 0.08), camera_fov=35, - max_FPS=60, + refresh_rate=60, ), show_viewer=cfg.simulation.vis, ), diff --git a/examples/genelab_examples/src/genelab_examples/gui_panels/env.py b/examples/genelab_examples/src/genelab_examples/gui_panels/env.py index 506afa06..eaeb33ac 100644 --- a/examples/genelab_examples/src/genelab_examples/gui_panels/env.py +++ b/examples/genelab_examples/src/genelab_examples/gui_panels/env.py @@ -45,7 +45,7 @@ def play(self) -> None: camera_pos=(2.6, 0.0, 1.6), camera_lookat=(0.0, 0.0, 0.4), camera_fov=40, - max_FPS=sim.render_fps or 60, + refresh_rate=sim.render_fps or 60, enable_gui=True, # the ImGui overlay that hosts our panels ), show_viewer=True, diff --git a/examples/wuji/src/genelab_wuji/deploy/config.py b/examples/wuji/src/genelab_wuji/deploy/config.py index db1e44ef..6dd0fdf3 100644 --- a/examples/wuji/src/genelab_wuji/deploy/config.py +++ b/examples/wuji/src/genelab_wuji/deploy/config.py @@ -6,14 +6,20 @@ * **Encoder / hardware order** = ``JOINT_NAMES_20`` = ``REORIENT_JOINT_POS`` keys = ``wujihandpy``'s (5, 4) row-major flatten: **finger-major** (finger1_joint1..4, finger2_joint1..4, ...). This is what ``read_encoders`` / ``write_target`` speak. -* **Policy / Genesis articulation order** = ``POLICY_JOINT_NAMES``: **joint-major** - (finger1..5_joint1, then finger1..5_joint2, ...). Genesis orders the articulation - this way regardless of the MJCF element order, so the trained policy's obs and - action are joint-major. +* **Policy order** = ``POLICY_JOINT_NAMES``: **joint-major** (finger1..5_joint1, + then finger1..5_joint2, ...). Genesis 1.0.x enumerated the articulation + breadth-first — joint-major for this hand — and the shipped checkpoint was + trained under that order, so its obs and action layouts are joint-major. ``DeployController`` remaps encoder->policy on read and policy->encoder on write via -``ENC_TO_POLICY`` / its inverse. ``tests/test_examples_wuji_deploy_joint_order.py`` -pins ``POLICY_JOINT_NAMES`` against the actual built env so the constant can't drift. +``ENC_TO_POLICY`` / its inverse. + +Genesis 1.2+ parses kinematic trees depth-first, so a *live* articulation now +enumerates finger-major (identical to the encoder order). ``POLICY_JOINT_NAMES`` +tracks the trained artifact, not the live env; a policy retrained under Genesis +1.2 has finger-major layouts and needs ``ENC_TO_POLICY`` updated to the identity. +``tests/test_examples_wuji_deploy_joint_order.py`` pins the env's depth-first +enumeration so any future Genesis ordering change is caught there. """ from __future__ import annotations diff --git a/examples/wuji/src/genelab_wuji/wuji_hand/sim.py b/examples/wuji/src/genelab_wuji/wuji_hand/sim.py index 1c27630e..7f2bff9e 100644 --- a/examples/wuji/src/genelab_wuji/wuji_hand/sim.py +++ b/examples/wuji/src/genelab_wuji/wuji_hand/sim.py @@ -216,7 +216,7 @@ def run_wuji_hand(config: WujiHandRunConfig) -> None: camera_pos=(0.35, -0.45, 0.32), camera_lookat=(0.0, 0.0, 0.08), camera_fov=35, - max_FPS=60, + refresh_rate=60, ), vis_options=gs.options.VisOptions( ambient_light=(0.35, 0.35, 0.35), diff --git a/pyproject.toml b/pyproject.toml index 93f12b38..7b935cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "genelab" -version = "0.3.3" +version = "0.4.0" description = "Isaac Lab API for RL and robotics research powered by Genesis." readme = "README.md" license = "Apache-2.0" @@ -9,8 +9,9 @@ requires-python = ">=3.12" authors = [{ name = "Chenhao Zhang", email = "charles040318@gmail.com" }] maintainers = [{ name = "Chenhao Zhang", email = "charles040318@gmail.com" }] dependencies = [ - # Pin to Genesis 1.0.x: 1.1.x changes parts of the API GeneLab relies on. - "genesis-world>=1.0.0,<1.1.0", + # Pin to Genesis 1.2.x: Genesis changes APIs GeneLab relies on across minor + # releases (1.1.x/1.2.x both did), so cap at the next minor until validated. + "genesis-world>=1.2.0,<1.3.0", "typer>=0.12", "rich>=13", "questionary>=2", diff --git a/src/genelab/__init__.py b/src/genelab/__init__.py index 62c2c33f..fb88493b 100644 --- a/src/genelab/__init__.py +++ b/src/genelab/__init__.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -__version__ = "0.3.3" +__version__ = "0.4.0" __all__ = [ "ManagerBasedEnvCfg", diff --git a/src/genelab/configs.py b/src/genelab/configs.py index 8fb6f2e2..e777053d 100644 --- a/src/genelab/configs.py +++ b/src/genelab/configs.py @@ -38,13 +38,13 @@ class SimulationCfg: dt: float = 0.01 substeps: int = 4 num_envs: int = 1 - # Viewer FPS cap, decoupled from the physics rate (``1/dt``). Forwarded to - # ``gs.options.ViewerOptions(max_FPS=...)`` when ``vis=True``. ``None`` runs the - # viewer uncapped. ``ManagerBasedRlEnv`` only refreshes the viewer on the last tick - # of its decimation loop, so the effective render rate is - # ``min(render_fps, 1/(dt*decimation))``; if the control rate exceeds ``render_fps``, - # the viewer's rate-limit will throttle the env step itself — lower ``render_fps`` or - # raise ``decimation`` if that's not what you want. + # Viewer repaint rate, decoupled from the physics rate (``1/dt``). Forwarded to + # ``gs.options.ViewerOptions(refresh_rate=...)`` when ``vis=True``. Genesis paces a + # viewer-enabled sim to wall-clock real time (``realtime_factor=1.0``) by default; + # ``None`` disables that pacer (``realtime_factor=None``) so the sim runs uncapped, + # with repaints at Genesis's default rate. ``ManagerBasedRlEnv`` only refreshes the + # viewer on the last tick of its decimation loop, so the real-time pacer sleeps once + # per control step rather than once per physics tick. render_fps: int | None = 60 # Initial viewer camera framing, forwarded to ``gs.options.ViewerOptions(camera_pos=..., # camera_lookat=...)`` when ``vis=True``. ``camera_lookat`` is also the trackball pivot, diff --git a/src/genelab/envs/manager_based_rl_env.py b/src/genelab/envs/manager_based_rl_env.py index ba9292c4..5f9b0f23 100644 --- a/src/genelab/envs/manager_based_rl_env.py +++ b/src/genelab/envs/manager_based_rl_env.py @@ -351,9 +351,9 @@ def step( action = action.to(self._device, non_blocking=True) self.action_manager.process_action(action) # Render only on the final physics tick of the decimation loop. Genesis's viewer - # update calls ``rate.sleep`` at ``ViewerOptions.max_FPS``, so updating every - # tick coupled the wall-clock rate to the physics rate and produced slow-motion - # playback at large decimation values. + # update sleeps in its real-time pacer (``ViewerOptions.realtime_factor``), so + # updating every tick coupled the wall-clock rate to the physics rate and produced + # slow-motion playback at large decimation values. last_tick = self._decimation - 1 for tick in range(self._decimation): self.action_manager.apply_action() diff --git a/src/genelab/scene/interactive_scene.py b/src/genelab/scene/interactive_scene.py index 3925bed1..d525aa64 100644 --- a/src/genelab/scene/interactive_scene.py +++ b/src/genelab/scene/interactive_scene.py @@ -42,8 +42,17 @@ def _viewer_option_kwargs(sim_cfg: "SimulationCfg", *, enable_gui: bool) -> dict Camera framing is only forwarded when set so an unconfigured scene keeps Genesis' own default camera; ``camera_lookat`` doubles as the trackball pivot, so framing the subject is what makes the mouse-wheel zoom close in on it. + + Genesis 1.1+ split the old ``max_FPS`` (one knob that throttled both repaints and the + stepping loop) into ``refresh_rate`` (repaint cap) and ``realtime_factor`` (sim pacing, + 1.0 = wall-clock real time). ``render_fps`` maps onto ``refresh_rate``; ``render_fps=None`` + keeps the documented "uncapped" contract by disabling the real-time pacer instead. """ - kwargs: dict[str, Any] = {"max_FPS": sim_cfg.render_fps, "enable_gui": enable_gui} + kwargs: dict[str, Any] = {"enable_gui": enable_gui} + if sim_cfg.render_fps is not None: + kwargs["refresh_rate"] = sim_cfg.render_fps + else: + kwargs["realtime_factor"] = None if sim_cfg.camera_pos is not None: kwargs["camera_pos"] = sim_cfg.camera_pos if sim_cfg.camera_lookat is not None: @@ -64,12 +73,17 @@ def _viewer_option_kwargs(sim_cfg: "SimulationCfg", *, enable_gui: bool) -> dict def find_imgui_panel_host(viewer: Any) -> Any | None: """Return the viewer plugin exposing ``register_panel`` (Genesis's ImGui overlay), or None. - Genesis appends an ``ImGuiOverlayPlugin`` to ``viewer._viewer_plugins`` when the scene is - built with ``ViewerOptions(enable_gui=True)``. We duck-type rather than import the plugin - class so this stays robust across Genesis point releases — any plugin that exposes a - ``register_panel`` callable is a valid host. + Genesis registers an ``ImGuiOverlayPlugin`` on the viewer when the scene is built with + ``ViewerOptions(enable_gui=True)``. Genesis 1.2 exposes the registered plugins through the + public ``viewer.plugins`` property (the old ``_viewer_plugins`` attribute was renamed); + we read the public property and keep the legacy attribute as a fallback. We duck-type + rather than import the plugin class so this stays robust across Genesis point releases — + any plugin that exposes a ``register_panel`` callable is a valid host. """ - for plugin in getattr(viewer, "_viewer_plugins", None) or []: + plugins = getattr(viewer, "plugins", None) + if plugins is None: + plugins = getattr(viewer, "_viewer_plugins", None) or [] + for plugin in plugins: if callable(getattr(plugin, "register_panel", None)): return plugin return None @@ -576,10 +590,9 @@ def build(self) -> None: if getattr(self._scene_cfg, "batch_render", False) else None ) - # Render rate is configured separately from the physics rate: Genesis throttles - # ``_visualizer.update`` via ``ViewerOptions.max_FPS``. We only construct - # ``ViewerOptions`` when the viewer is enabled so headless training stays - # untouched. + # Render rate is configured separately from the physics rate: Genesis caps viewer + # repaints via ``ViewerOptions.refresh_rate``. We only construct ``ViewerOptions`` + # when the viewer is enabled so headless training stays untouched. # A non-empty ``panels`` list implies the ImGui overlay even if ``viewer_imgui`` was # left False, so "add a panel" is a one-liner that just works. enable_gui = bool(self._sim_cfg.viewer_imgui) or bool( diff --git a/tests/test_examples_wuji_deploy_joint_order.py b/tests/test_examples_wuji_deploy_joint_order.py index b2f59513..397d4437 100644 --- a/tests/test_examples_wuji_deploy_joint_order.py +++ b/tests/test_examples_wuji_deploy_joint_order.py @@ -1,9 +1,17 @@ """Pin the deploy joint-order remap against the real Genesis articulation order. -The encoder / wujihandpy order (``JOINT_NAMES_20``) is finger-major; the policy / -Genesis articulation order (``POLICY_JOINT_NAMES``) is joint-major. ``DeployController`` -remaps between them. If they drift, the real hand gets scrambled joint obs + actions and -twitches without manipulating the cube (the real-hand 0%-success bug). +The encoder / wujihandpy order (``JOINT_NAMES_20``) is finger-major; the shipped +policy's order (``POLICY_JOINT_NAMES``) is joint-major — Genesis 1.0.x enumerated +the articulation breadth-first, and the deployed checkpoint was trained under that +order. ``DeployController`` remaps between them. If they drift, the real hand gets +scrambled joint obs + actions and twitches without manipulating the cube (the +real-hand 0%-success bug). + +Genesis 1.2 parses kinematic trees depth-first, so the live articulation now +enumerates finger-major (identical to the encoder order). ``POLICY_JOINT_NAMES`` +deliberately stays the *trained artifact's* order; the env-order drift guard below +pins the new depth-first enumeration instead, so any future Genesis ordering change +is caught again. """ import numpy as np @@ -35,15 +43,24 @@ def test_default_policy_is_default_reordered() -> None: assert np.allclose(dp[np.argsort(ENC_TO_POLICY)], d) -def test_policy_joint_order_matches_env() -> None: - """Drift guard: POLICY_JOINT_NAMES must equal the built env's articulation order.""" +def test_env_joint_order_is_depth_first() -> None: + """Drift guard: the built env must enumerate joints depth-first (finger-major). + + Genesis 1.2 parses kinematic trees depth-first, which for the hand coincides + with the encoder order ``JOINT_NAMES_20``. A policy trained under Genesis 1.2 + therefore has finger-major obs/action layouts, while the shipped checkpoint + (trained under 1.0.x, breadth-first) keeps the joint-major ``POLICY_JOINT_NAMES`` + — sim2sim replay of that checkpoint needs the name-based remap, and a retrained + policy needs an updated ``ENC_TO_POLICY``. This assert exists so any future + Genesis enumeration change is caught here instead of as scrambled joints. + """ pytest.importorskip("genesis") from genelab_wuji.deploy.scripts._env import build_reorient_env env = None try: env = build_reorient_env(num_envs=1) - assert list(env.scene["robot"].joint_names) == list(POLICY_JOINT_NAMES) + assert list(env.scene["robot"].joint_names) == list(JOINT_NAMES_20) except Exception as exc: # asset download / GPU / display unavailable in minimal CI if env is None: pytest.skip(f"reorient env unavailable: {exc}") diff --git a/uv.lock b/uv.lock index f2133bf8..7af5eda9 100644 --- a/uv.lock +++ b/uv.lock @@ -844,7 +844,7 @@ wheels = [ [[package]] name = "genelab" -version = "0.3.3" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "genesis-world" }, @@ -936,7 +936,7 @@ skrl = [ [package.metadata] requires-dist = [ - { name = "genesis-world", specifier = ">=1.0.0,<1.1.0" }, + { name = "genesis-world", specifier = ">=1.2.0,<1.3.0" }, { name = "gymnasium", marker = "extra == 'rl'", specifier = ">=1.0" }, { name = "gymnasium", marker = "extra == 'sb3'", specifier = ">=1.0" }, { name = "imgui-bundle", marker = "extra == 'imgui'" }, @@ -999,7 +999,7 @@ skrl = [{ name = "skrl", specifier = ">=1.4.0,<2" }] [[package]] name = "genesis-world" -version = "1.0.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coacd" }, @@ -1036,7 +1036,7 @@ dependencies = [ { name = "z3-solver" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/59/1d9257f7e5687c448f7fa251193e17e487ed859018350c14331785af1717/genesis_world-1.0.0-py3-none-any.whl", hash = "sha256:202273df5f8b26b24f6abeaf0f1efeef77c195b10d2104513414f0cb853d34a5", size = 82957659, upload-time = "2026-05-27T15:40:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/37/b7/72d88b43b9965ea575e2733e0aefd838c4610aaa1f751b0b07977d6af65a/genesis_world-1.2.0-py3-none-any.whl", hash = "sha256:1842bf3055decb9b65415bca1b22261cf3b30c128b72a7a92ef2e493b3d71231", size = 83206705, upload-time = "2026-06-23T09:55:26.729Z" }, ] [[package]] @@ -3510,7 +3510,7 @@ wheels = [ [[package]] name = "quadrants" -version = "0.8.0" +version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, @@ -3522,14 +3522,14 @@ dependencies = [ { name = "setuptools" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/69/728f33e16e8612de22f3e95d6291bce6f22131067c897d3b5eb7ea1706bf/quadrants-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07e223958f51c75cabe4631b5446db0ec22fa9f9818a80c69581a8893db090dd", size = 30430357, upload-time = "2026-05-16T13:21:52.452Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f7/f1b0ad69914a336f78d6f6d2d6658875f21b5060ebf80f29e847e3d2c442/quadrants-0.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_34_aarch64.whl", hash = "sha256:a979c495027d74f080563bd8bc1978fabdc23881d52b284516b3b658950034d3", size = 44981280, upload-time = "2026-05-16T13:55:39.155Z" }, - { url = "https://files.pythonhosted.org/packages/05/c1/f3a8de448bfdef42507d82b97264b2f5c0383067ba10ec438d13a84243fe/quadrants-0.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ee51b7299882dd78bdc04ce2385566dbcbb59bcf0b2ce0951af7a1ddfe51a40", size = 48082021, upload-time = "2026-05-16T13:55:42.327Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f7/b6bd12453bde9a73d49a2579aa5c0ac57dbadd45acec704f704d8958429f/quadrants-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:897d654ac56ab087aabe43058a4ab5970460604e3a2129f52850d32ff97b3162", size = 55617168, upload-time = "2026-05-16T12:53:50.483Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e9/e19b9980713daabdd07955f673359728fe1c311bf4ec4c845f969c054536/quadrants-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ee69ff07aa84f5d28dd5c4f99cdbc67139dad45abedb0e7dbe7552a45017a53", size = 30430931, upload-time = "2026-05-16T13:21:52.342Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/6998c451a1c2dea218906bd206b8e82a622a449fbe8022fa6905cb1decd8/quadrants-0.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_34_aarch64.whl", hash = "sha256:971c4bbd786a1a22412922e40865ce17821dcef0359e5ce7ac5cc9eab19d3062", size = 44982500, upload-time = "2026-05-16T13:55:38.463Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ca/19adad282cc3b500eb0cf08fc2ed1ea13ce697e5c31aafd06fecca182668/quadrants-0.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58db773247a31ab7badb9bbdff3564b889892fa74b92b714ccd91a3f023d1c1c", size = 48082323, upload-time = "2026-05-16T13:55:46.648Z" }, - { url = "https://files.pythonhosted.org/packages/25/42/97501f41456a977e3092244f1e7327a2e313d5ab58081f86b9eb860610f5/quadrants-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:cded00f903f2d82d573c553c3d7fe16bdd29deba1679d66f67a95139065794ee", size = 55617709, upload-time = "2026-05-16T12:53:53.419Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/63180297f0bcd11f1a0e83d46977747f095f8d3444b6689a642fd93475fe/quadrants-1.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb02ed33ce13d784890e097aed7d46e847341e0d5918bec61404537a9d5a1305", size = 30505796, upload-time = "2026-06-04T02:42:59.251Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/1bd4b6cccbd20101541c2b5a3d81538ccaad52209ac67da7ef33d32044c7/quadrants-1.0.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_34_aarch64.whl", hash = "sha256:e703cc8b4c6a29864f1d936c44c6e75c23b5c0da05b4b137ef71ba39c62d1b5d", size = 45042090, upload-time = "2026-06-04T02:59:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/57/1d/99b5070e4624bb5aa6ed63338d246641b6262dae1efa1c0762c1371f775f/quadrants-1.0.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e6d9103efcc145c31a1c9496a906f60121346ee219908de86d7e63d787d110", size = 48136824, upload-time = "2026-06-04T02:59:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/58e8f91851c01fcdf427cb2b33c837d8e023c0494e9a8adcc93cd17fc767/quadrants-1.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cf020b02ef6ab4acc526a02fe06c010ac9fe8c97a93e6b3c56e6010ed53a38af", size = 55669735, upload-time = "2026-06-04T02:17:40.479Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cd/e32306e2081caee9a1c6526e2e85ac4a0fc67ff3d96261195edea19b5f63/quadrants-1.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be0ba10e219d714bb882d3a569344ff0c5aa057084049634808ab2aa5eefed0b", size = 30506454, upload-time = "2026-06-04T02:43:07.342Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/352606f1c97679dc7045c4db1c4bea20d6022cda81dadef4fc46b192ac18/quadrants-1.0.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_34_aarch64.whl", hash = "sha256:4218dfb79cec92b2e978537000c534f3ce70ac70d66d8faff2dd2bdad18ab3d0", size = 45044337, upload-time = "2026-06-04T02:59:29.928Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/50c679bfc7bf43e4065bd3b8a74ad2e8eb53c504751a7ec1909911c5f356/quadrants-1.0.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ba032a88b6c44903fc549f652a0ec1cf287b1a3e1031e276ecae731bfa9737", size = 48141728, upload-time = "2026-06-04T02:59:30.757Z" }, + { url = "https://files.pythonhosted.org/packages/96/dd/049f63a4cad6299cbaed51fa19f897cad74cc34e152877ce5460f7a69704/quadrants-1.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:c57fa8be0a288492417ae437c124dc0f0564d76df1d5a700d620aec50386116d", size = 55668754, upload-time = "2026-06-04T02:17:44.294Z" }, ] [[package]]